Skip to content

Matcher Follow-Up: Remove monolithic matcher for Java programs (compiler) + move more matching logic to ncore#3922

Merged
unp1 merged 24 commits into
mainfrom
bubel/program-matcher-single-source
Jul 16, 2026
Merged

Matcher Follow-Up: Remove monolithic matcher for Java programs (compiler) + move more matching logic to ncore#3922
unp1 merged 24 commits into
mainfrom
bubel/program-matcher-single-source

Conversation

@unp1

@unp1 unp1 commented Jul 13, 2026

Copy link
Copy Markdown
Member

Intended Change

The previous introduction of the matcher based on "compilation" (partial evaluation) used a monolithic program matcher with fall-backs to the matching code in the AST classes. This PR breaks the monolithic matcher in single instructions and removes any dependency on AST internal matching methods.

Advantages:

  • it prepares for the removal of the interpreter matcher as the compiler does not need its infrastructure for program matching and as safety fallback anymore.
  • it moves generic parts of the program matching logic to ncore facilitating reuse across provers
    These changes make the compiler adaption easier for other provers like RustyKeY and SolidiKeY

In addition JavaDoc comments were partially reworded to make them easier to read. In particular wording referring to the development process of the PR have been removed. They are now more focused on describing the "is" state than on how the "is" state was achieved.

Changes in key.ncore.matcher

The generic machinery lives in key.ncore.matcher, renamed from key.ncore.compiler by a pure move that leaves the package names unchanged (the ncore modules still do not depend on key.core). This PR adds the program half of the framework, mirroring the term-side plans already present: ProgramMatchPlan nodes (ProgramLeafPlan, ProgramStructuralPlan, ProgramListSVPlan) for the program inside a modality, and ProgramChildSequence for the fixed- and variable-arity walk over a node's children via a generic positional cursor. The calculus-specific parts stay behind two small SPIs: ProgramMatchHook for entering a modality's program and ListSVMatcher for list schema variables.

Changes in key.core

JavaProgramMatchPlanBuilder describes each Java program construct once, from plain statements and value literals to schematic field references and the .. ... context statement blocks of symbolic-execution taclets. The compiled back-end then matches purely from the structure of pattern and source, calling no AST match(SourceData, MatchConditions) method, so the monolithic JavaProgramCompiler is removed. A construct the dispatch does not describe fails at taclet-load time with a clear error (the same behaviour the term side already has) instead of silently matching through the AST; no construct in the current taclet base reaches this. The interpreter back-end is left as is and serves as the reference against which the compiled matcher is validated.

Type of pull request

  • Refactoring (behaviour should not change or only minimally change)
  • There are changes to the (Java) code
  • There are changes to the deployment/CI infrastructure (gradle, github, ...)

Ensuring quality

  • I made sure that introduced/changed code is well documented (javadoc and inline comments).
  • I added new test case(s) for new functionality.
  • I have checked that runtime performance has not deteriorated.
  • I have tested the feature as follows:
    • Byte-identity validated with a differential oracle that ran the monolithic AST matcher alongside every plan match over the whole RunAllProofs corpus: 53,118,760 match datapoints, 0 mismatches, re-run after every stage of the series (context integration, list SVs, variable-arity contexts, safety-net retirement, framework extraction, term-side generalization, binder rework). The oracle is development tooling and is kept out of this branch.
    • Instrumented fallback count over the full taclet base: 0 interpreter fallbacks and 0 compiled legacy fallbacks before the net was removed; the removal itself is compile-guaranteed unreachable for the shipped base (RunAllProofs loads every taclet).
    • Full RunAllProofs green; full key.core test suite green
    • Node-identical proofs: the 8 benchmark proofs of the mt-goals series close with identical node counts before/after, seq wall-time within noise (±3.5 %). Capped smoke runs (100k rule applications) on large SE-heavy proofs (TimSort binarySort/mergeLo/mergeHi/sort, a wide-branching case study) are node-identical as well.
    • The modality head's compiled program entry reads Modality.programBlock(), the same object JTerm.javaBlock() returns for a modality term; the binder rework routes unbinding through the framework wrapper at the same point in the instruction stream. Both re-validated by the oracle run above.
    • Re-verified on the multi-core prover after rebasing onto it: the parallel-prover gates (2 and 4 workers, the stress suite) and both shared-state lints pass, and a run-to-run variance probe at 4 workers reports zero spread in nodes and rule applications. The benchmark proofs close with node counts identical to main, single-threaded and at 4 workers.
    • Every commit in the series compiles on its own; matcher unit tests and spotless pass on the tip.
    • Adversarially reviewed: independent line-by-line equivalence reviews of the context-match and list-SV translations against the AST originals (7 review lenses); the two findings they produced are the documented clean-fail-vs-NPE corner and the re-match alignment above.

Additional information and contact(s)

Created with AI tooling support

The contributions within this pull request are licensed under GPLv2 (only) for inclusion in KeY.

@unp1 unp1 self-assigned this Jul 14, 2026
@unp1 unp1 added Prover Core 🛠 Maintenance Code quality and related things w/o functional changes labels Jul 14, 2026
@unp1 unp1 added this to the v3.1.0 milestone Jul 14, 2026
unp1 added 20 commits July 15, 2026 09:16
Rename key.ncore.compiler -> key.ncore.matcher and move the interpreter
VM framework (org.key_project.prover.rules.matcher.vm) into it from
key.ncore.calculus. The two halves of the match-plan framework now live
in one module: the single-source compiler (matcher.compiler) and the
interpreter back-end it derives (matcher.vm).

Package FQNs are unchanged, so this is a pure module-location move: no
source edits in the key.core realizations, only the module name in
settings.gradle and key.core's dependency. The dependency direction is
preserved (key.core -> key.ncore.matcher -> key.ncore.calculus ->
key.ncore); nothing in the ncore modules points back at core, so the
framework stays reusable by other provers.

Created with AI tooling support
…sion

For a schema-variable pattern carrying bound variables, the interpreter
emission added one gotoNextSibling per bound variable AFTER the
schema-variable match had already advanced past the whole term. The
schema-variable instruction matches at the term node and never descends,
so those skips move the cursor at the sibling level -- past elements
that do not belong to the pattern. (In OperatorPlan the same skips are
correct because its cursor first descends into the term's children.)

The class comment always described the correct sequence ("matchSV +
gotoNextSibling, wrapped in bind/unbind"); the code now matches it. The
defect was lifted verbatim from the pre-framework generator (see
13e8d13^), and the path is dead in practice: an instrumented load of
the standard taclet base finds no schema-variable pattern with bound
variables (bound variables attach to binder operators). The compiled
back-end (element-based, no cursor) was already consistent.

Created with AI tooling support
…afe flag

Consistency pass over the term-side match-plan framework and its Java-DL
front-end, aligning them with the (reified) program side:

* toString on the term plan nodes and heads (OperatorPlan, SchemaVarPlan,
  GenericOperatorHead, ModalityHead, ParametricFunctionHead,
  ElementaryUpdateHead, MatchSchemaVariableInstruction), so a find
  pattern's whole plan tree renders for debugging, like the program
  plans already do.
* MatchPlan.emitInstructions renamed to emit -- one name for the
  interpreter emission across MatchPlan, MatchHead and ProgramMatchPlan.
* The key.matcher.programInstructions property moves from the legacy
  SyntaxElementMatchProgramGenerator to JavaMatchPlanBuilder (its only
  reader): the framework no longer references a class slated for
  deletion.
* MatchPlan.compile() documents the called-once contract; a comment in
  OperatorPlan.emit explains why the bound-variable skips are correct
  there (the cursor descended into the term first).
* Javadoc line-wrap artifacts (orphan words) smoothed in the framework
  and front-end docs.

No behavioural change.

Created with AI tooling support
Comment-only. Class docs added for the instructions that had none
(program SV, variable SV, modal-operator SV, generic sort,
parametric-function similarity, bind/unbind, schema-variable base) and
sharpened where they were one-liners (term labels, instruction-set
factory); javadoc wrap artifacts smoothed in the sub-program and
context-block instructions.

Created with AI tooling support
A schema-variable pattern never carries bound variables: bound
variables attach to binder operators (matched by OperatorPlan), and an
instrumented load of the standard taclet base confirms no
schema-variable pattern with bound variables exists. Remove the
bind/unbind scaffolding from SchemaVarPlan (its constructor now takes
only the schema-variable instruction) and let JavaMatchPlanBuilder
treat the shape as unsupported -- it falls back with the framework's
clear "no head for this find pattern" error instead of silently
matching through untested code.

Deliberately a single, isolated commit: reverting it restores the
corrected implementation from the previous fix commit, should the
shape ever become constructible.

Created with AI tooling support
The program half of the single-source match framework becomes generic,
mirroring the term half that already lives here: a language front-end
describes each program construct once as a ProgramMatchPlan, and both
matcher back-ends are derived from it. New in
org.key_project.prover.rules.matcher.compiler:

* ProgramMatchPlan -- the plan interface (emit / compile / listSV /
  interpretable);
* ProgramLeafPlan -- an element matched by a single front-end-supplied
  instruction, shared by both back-ends;
* ProgramStructuralPlan -- the default structural match: same concrete
  class as the pattern, an optional language-supplied guard, children
  matched recursively (fixed arity directly, variable arity through a
  cursor walk);
* ProgramListSVPlan -- a list schema variable, driven by its enclosing
  plan;
* ProgramChildSequence -- the mixed fixed/list child walk, owning the
  greedy list-SV run;
* ProgramChildCursor -- the positional child cursor over SyntaxElement
  (the language-agnostic counterpart of the per-language SourceData
  classes);
* ListSVMatcher -- the language SPI for list schema variables (element
  admissibility and typed recording of the matched run), alongside the
  existing BinderMatcher and ProgramMatchHook axes.

MatchProgramElementInstruction (exact class + child count) moves to the
generic instruction package -- it reads only SyntaxElement.

The package description now covers both symmetric halves and lists
exactly what a language front-end supplies -- a dispatch, its schema
variable instructions, and the three SPIs plus per-operator heads --
versus what the framework shares: the plugging-in guide for prover
front-ends.

The module still depends on nothing above key.ncore.calculus. The Java
front-end switches to this framework in the follow-up commit.

Created with AI tooling support
JavaProgramMatchPlanBuilder describes every Java construct of a find
pattern's program once, as a ProgramMatchPlan from the generic
framework, and both matcher back-ends are derived from that single
description. The dispatch covers: fixed-arity structural elements,
value leaves (literals, empty statements), schema type references,
dimension-guarded type references and variable specifications,
schematic field references, concrete program variables and methods,
variable-arity list schema variables, and context statement blocks
(the ubiquitous {@code .. ...} pattern) -- including the
variable-length prefix descent, the inner execution context
({@code .#ex..} / {@code .#pm@#t(#v)..}, matched by ordinary
sub-plans), the active statements, and the recorded prefix/suffix
positions. The compiled back-end reads only the structure of pattern
and source; it calls no AST {@code match(SourceData, MatchConditions)}
method at all (SourceData remains the cursor of the interpreter's AST
matchers only).

The Java-DL judgements plug into the framework's SPIs: the new
JavaListSVMatcher singleton supplies the two list-SV judgements
(admissibility via ProgramSVSort.canStandFor in the recorded execution
context; runs stored as ImmutableArray<ProgramElement>,
agree-or-record). The Java-only field-reference and context-block
nodes remain in the front-end, composing the generic child walk.

The legacy compiled program matcher (JavaProgramCompiler) is deleted.
A program the dispatch does not describe fails at taclet-load time
with the framework's "no head for this find pattern" error -- the same
contract the term side has -- instead of silently matching through the
AST; an instrumented load of the standard taclet base and the full
RunAllProofs corpus hit zero such fallbacks before the net was
removed. The interpreter back-end is untouched and remains the
reference implementation: its converted-instruction mode now asks the
dispatch per element first (one description for both back-ends), and
the monolithic MatchProgramInstruction stays as its safety net, newly
documented in that role, together with a rework of the context-match
documentation on ContextStatementBlock for readers new to it.

The equivalence of plan-based and AST program matching was validated
with a differential oracle running both matchers side by side over the
full RunAllProofs corpus: 53,419,581 match datapoints, 0 mismatches.
TestMatchTaclet gains cases for list schema variables and
variable-arity context blocks.

Created with AI tooling support
A schema variable that is already bound matches a program element again
only if the element IS its binding -- the bound program element itself,
or, for a term binding, the term's operator. MatchProgramSVInstruction
accepted more: it converted the candidate to a term and compared the
terms, so a schema variable bound to a literal term could re-match the
corresponding literal program element where ProgramSV.match (the AST
matcher, used by the interpreter back-end) rejects it. Rule
applications driven by proof scripts pre-bind schema variables and can
reach this difference; ordinary find-matching over the whole RAP corpus
cannot (validated unchanged either way).

Both matcher back-ends and the AST matcher now agree on the rule.

Created with AI tooling support
The term half of the match framework gains the same division of labour
the program half already has: the language-independent walk lives in
key.ncore.matcher, the language supplies only its own decisions.

* MatchPlanBuilder -- the abstract skeleton of a term-side dispatch: it
  recurses over a find pattern (operator, subterms, bound variables),
  composes SchemaVarPlan / OperatorPlan nodes, and owns the
  fail-at-load contract ("no head for this find pattern"). A front-end
  subclass supplies the schema-variable instructions, the per-operator
  head dispatch, its BinderMatcher, and an optional plan wrap for
  language-specific in-place checks (term labels in Java-DL).
* ModalityHead moves here and becomes language-agnostic: the modal
  kind and the program are the modality operator's two syntax children
  (Modality.kind() / Modality.programBlock() -- an abstraction all
  provers' modalities share), so the head needs only a
  front-end-supplied kind instruction and the ProgramMatchHook. The
  Java dispatch constructs it directly; the compiled program entry
  reads the operator's programBlock(), the same object
  JTerm.javaBlock() returns for a modality term.
* MatchByEqualsInstruction (value-carrying leaves by equals) moves to
  the generic instruction package beside MatchIdentityInstruction --
  it reads only SyntaxElement.

The Java front-end still carries its own copy of the term recursion;
it switches to the skeleton in the follow-up commit.

Created with AI tooling support
JavaMatchPlanBuilder now extends the framework's MatchPlanBuilder and
supplies only the Java-DL decisions: the schema-variable instruction
factory, the per-operator head dispatch (elementary update, parametric
function instance, modality, generic default), the JavaBinderMatcher,
and the term-label wrap (labels stay a Java-DL concern; the framework
only offers the wrap hook). The recursion over the pattern, the
derivation of interpreter program and compiled matcher, and the
fail-at-load error are inherited.

Two shared dispatch instances carry the one piece of front-end state,
the key.matcher.programInstructions flag; the static facades keep
their signatures, so callers are unchanged.

Created with AI tooling support
Comment-only. The framework and front-end docs describe the current
state of each class in its own terms: what a node matches, what each
back-end does, and what a front-end supplies. References that compared
a class to the matcher fragments it was derived from, lifecycle
remarks, and mentions of individual prover front-ends are gone -- the
framework is not specific to any set of provers. Domain notions
(schema variable, elementary update, parametric function instance) are
glossed where a class doc first uses them.

Created with AI tooling support
…r operation

BinderMatcher exposes each binding operation once: binder(boundVars)
(element-based, applied by both back-ends as it is) and
unbind(mc, boundVars) (a pure state transformation). The interpreter
form of unbind is derived by the framework -- OperatorPlan wraps the
call into a cursor-neutral instruction -- instead of being a second
SPI method, so the back-end split lives in the framework, not in the
front-end contract.

unbind receives the bound variables because closing a scope is not
scope-structured in every binding representation: a front-end that
keeps a counted stack of the variables bound along the current path
(for example for de-Bruijn-indexed terms) pops exactly
boundVars.size() entries and needs the count, while Java-DL's nested
renaming table pops one scope and ignores the argument.

The Java-DL UnbindVariablesInstruction and its factory method (whose
parameter was unused) are deleted; the framework's wrapper performs
the same MatchConditions.shrinkRenameTable() at the same point in the
instruction stream.

Created with AI tooling support
Comment-only. Each class doc that speaks of an SPI expands the
abbreviation at its first use ("SPI (service provider interface)");
passing mentions name the interface instead. The Java program-match
hook's doc names the SPI it implements, like the other two Java-DL
SPI implementations.

Created with AI tooling support
A pre-compiled child of a ProgramChildSequence is either a list schema
variable or a fixed child's matcher. This alternative is now a sealed
interface with one record per kind, instead of a record with two
nullable fields whose either-or invariant lived only in a comment --
the nullness checker verifies the dereferences now, and a violation of
the fixed-arity contract of matchOneToOneFrom fails as a cast error
instead of a null dereference.

Created with AI tooling support
VMProgramInterpreter.match and matchChildrenFrom returned the borrowed
PoolSyntaxElementCursor to the pool after the match loop, but not on
the exception path -- an instruction throwing mid-match abandoned the
cursor (the pool then allocates a fresh one, so this is resource
hygiene, not a correctness bug). The release now runs in a finally.

Created with AI tooling support
TestMatchTaclet gains a case for a find pattern whose modality program
is a plain statement block without the .. ... context markers and with
a fixed (non-list) statement: \find(\<{ #slhs1 = #e; }\>post) matched
against \<{ i = 1; }\>true. Such a program is matched structurally as
a whole (exact class and child count per element); the existing
non-context cases all used list schema variables or empty blocks, so
this shape had no direct test.

Created with AI tooling support
…ies on

A concrete generic argument of a parametric function instance is
matched by reference identity on its GenericArgument. That is complete
because ParametricFunctionInstance.get interns instances by value: a
source operator with the same base and concrete arguments is the same
object as the pattern's and therefore shares its argument objects. A
comment states this at the match site, and
ParametricFunctionInterningTest pins the interning contract (equal
requests return one shared instance, arguments included) plus its
matcher-level consequence (an independently built equal instance
matches) -- so weakening the interner fails these tests instead of
making the matcher silently reject taclet applications.

(A pattern mixing a generic sort with a concrete argument in one
instance would not share its concrete argument objects with a source;
no multi-parameter parametric function exists in the shipped base, so
identity is kept for its cheapness on the hot matching path.)

Created with AI tooling support
Remove the separate key.matcher.interpreterAssumes property, which
forced the interpreter for a taclet's \assumes formulas independently
of its find pattern. The remaining key.matcher.interpreter property
(and its GUI feature flag) is now the single switch: it selects the
back-end for all of a taclet's matching, find and \assumes together.
The \assumes formulas follow the find's back-end unconditionally.

Behaviour is unchanged with the property off (the default): assumes
already followed the find back-end then. Only the ability to diverge
the two, a headless A/B knob, is dropped.

Created with AI tooling support
Which taclet matcher runs (compiled or interpreter) is a core setting,
not an optional extension, so it should not be a FeatureSettings
feature: features are meant for extensions and are all switched on at
once by --experimental, which would silently force the interpreter.

Drop INTERPRETER_MATCHER_FEATURE and its GUI registration anchor in
SettingsManager; the key.matcher.interpreter system property is the
sole switch. Default behaviour is unchanged (the property is off by
default, as the feature was); the only change is that --experimental
no longer swaps the matcher back-end.

Created with AI tooling support
…matcher

The lint against shared mutable static state on the proving path walked
the classes of key.core only. Every taclet match runs through the match
framework, which this branch moves to key.ncore.matcher, so that code
left the guard's scope: a shared HashMap added there would not have been
reported.

Scan the matcher module's packages too. A module reaches the test
classpath either as its classes directory (the module the test lives in)
or as its jar (a module dependency), so class names are now read from
both layouts. A scanned package that yields no classes fails the test
instead of being skipped silently -- that is what let the scope go stale
unnoticed in the first place, and it mirrors the existing check for
stale allowlist entries.

Verified by injecting a static HashMap into a key.ncore.matcher class:
the lint fails, and passes again once removed.

Created with AI tooling support
@unp1
unp1 force-pushed the bubel/program-matcher-single-source branch from 54b82d3 to 145e353 Compare July 15, 2026 07:56
unp1 added 3 commits July 16, 2026 07:31
Comment-only. Em-dashes are replaced by plain punctuation throughout
the matcher's javadoc. The entry points introduce the domain notions
they use: the package description and the plan/builder docs say what a
taclet, a find pattern and a schema variable are before relying on
them. The class documentation of VMTacletMatcher describes what the
class is (the matcher of one taclet: find and assumes matchers built
through the match-plan framework, back-end chosen by the property,
results threaded through immutable match conditions) instead of the
approach it was inspired by, and VMProgramInterpreter's description is
reduced to its contract.

Created with AI tooling support
MatchGenericSortInstruction.matchSorts declared a result variable it
never used, next to a commented-out assertion referring to a variable
name that no longer exists. The information the assertion carried (the
source sort may itself be generic, because proving a taclet correct
matches schematic patterns against schematic terms) is now a comment.

Created with AI tooling support
…h, dead fallback

Findings of a correctness and thread-safety audit of the matching code
(both back-ends; no thread-safety findings):

* A modal-operator schema variable that was already instantiated
  silently took a new modality kind on a second match, because
  SVInstantiations.add overwrites without warning and the instruction
  had no instantiate-or-agree step (every other schema-variable
  instruction has one). It now matches an instantiated variable only
  against the same kind again. No taclet in the shipped base matches
  one modal-operator variable twice, so the rule base is unaffected.
* VMTacletMatcher.matchSV routed every candidate through the
  term-typed overload, so a program-element candidate handed to a
  term-kind schema variable failed with a ClassCastException instead
  of a failed match. Program elements now dispatch to the
  program-element overload, whose base implementation rejects the
  match for schema-variable kinds that stand for terms.
* matchAssumes rejects a template that is not one of the taclet's
  assumes formulas with a clear IllegalArgumentException instead of a
  NullPointerException.
* The compiled-or-interpreter fallback in matchProgramFor and the
  compiledProgramOrNull facades are removed: both back-ends derive
  from the same match plan, so the fallback could only ever rebuild
  the same failure and the branch was dead. Unsupported patterns fail
  at taclet-load time on either back-end, as documented.

Created with AI tooling support
A schema-variable match candidate is either a term or a program
element (an update's left-hand side, for example, is a program
variable). Previously the generic match(SyntaxElement) overload of
each schema-variable instruction meant "match a term" and cast
accordingly, program elements had a separate overload, and the program
schema variable hand-rolled the type split; VMTacletMatcher.matchSV
had to repeat that split at the call site.

The base class now routes each candidate once: its final
match(SyntaxElement) dispatches to an abstract match(JTerm) and to
match(ProgramElement), whose default rejects (only the program schema
variable overrides it). Subclasses implement typed methods without
casts, and matchSV is back to a single uniform call.

Created with AI tooling support
@unp1
unp1 enabled auto-merge July 16, 2026 07:06
@unp1
unp1 added this pull request to the merge queue Jul 16, 2026
Merged via the queue into main with commit ba42c20 Jul 16, 2026
39 checks passed
@unp1
unp1 deleted the bubel/program-matcher-single-source branch July 16, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠 Maintenance Code quality and related things w/o functional changes Prover Core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants