Skip to content

GH-5905 Add experimental query algebra equivalence checker - #5948

Open
hmottestad wants to merge 46 commits into
developfrom
GH-5905-algebra-equivalence
Open

GH-5905 Add experimental query algebra equivalence checker#5948
hmottestad wants to merge 46 commits into
developfrom
GH-5905-algebra-equivalence

Conversation

@hmottestad

Copy link
Copy Markdown
Contributor

GitHub issue resolved: #5905

Briefly describe the changes proposed in this PR:

This PR adds an experimental rdf4j-queryalgebra-equivalence core module for conservatively checking whether two native RDF4J query-algebra trees are equivalent before accepting an optimizer rewrite.

  • exposes proof-oriented equivalence results, semantic summaries and fingerprints, a replayable proof kernel, and a clone-isolated verified rewrite gate
  • supports RDF4J runtime, SPARQL 1.1, draft SPARQL 1.2, and shared specification profiles
  • defaults to bag observation, arbitrary incoming bindings, and proof-only checking; generated MemoryStore counterexample search remains opt-in
  • evaluates counterexamples with an empty optimizer pipeline and snapshots caller-owned datasets, bindings, evidence, and algebra plans
  • hardens runtime Join and Reduced semantics, canonical encodings, RDF 1.2 profile validation, proof trace integrity, and ambiguous ordering handling
  • registers the module in the query-algebra reactor and RDF4J BOM without adding it to the standard optimizer pipeline

Validation:

  • python3 .codex/skills/mvnf/scripts/mvnf.py core/queryalgebra/equivalence --retain-logs — 56 tests, 0 failures, 0 errors
  • final offline root -Pquick clean install — BUILD SUCCESS
  • repository copyright validation and formatter — passed

PR Author Checklist (see the contributor guidelines for more details):

  • my pull request is self-contained
  • I've added tests for the changes I made
  • I've applied code formatting (you can use mvn process-resources to format from the command line)
  • I've squashed my commits where necessary
  • every commit message starts with the issue number (GH-xxxx) followed by a meaningful description of the change

hmottestad and others added 30 commits July 12, 2026 21:19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two executable counterexamples where AlgebraEquivalenceChecker proves a
commuted join EQUIVALENT (JOIN_COMMUTATIVE normalization proof) while
evaluating both trees against a bundled dataset returns meaningfully
different results, and BoundedCounterexampleFinder verifies that same
dataset as a witness of non-equivalence:

- leftjoin-condition-blind-spot: externalReads(LeftJoin) ignores the
  condition expression, so a condition reading a sibling-bound variable
  (may-bind via a Union branch, exposed by the well-designed-left-join
  path) does not block commutation; the original binds ?c, the commuted
  tree does not.
- extension-overwrite-blind-spot: orderIsObservableAcrossOperands models
  reads and discards but not re-binding writes, so an Extension that
  overwrites a sibling-bound variable commutes; the original returns one
  row, the commuted tree returns none (differs even under ASK).

The tests pin the current defective verdicts; flip the EQUIVALENT
assertions to NOT_EQUIVALENT once IncomingBindingAnalyzer models
condition reads and re-binding writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BindingSetAssignment's opaque diagnostic encoding was derived from
getSignature(), which embeds the rows' toString(): equal binding sets
rendered with different column insertion order produced different
canonical encodings while canonical equality (ExactTreeEquality) said
the trees were equal, so ProofKernel.verify rejected the checker's own
normalization proof (AssertionError) and equal fingerprints hashed into
different buckets. StructuralKey now renders VALUES rows through the
order-insensitive CanonicalEncoding, with explicit placeholders for
unset and non-materialized row sources (a one-shot Iterable must not be
consumed by diagnostics). getSignature() itself no longer NPEs on the
API-legal unset-rows state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RDF4J literal equality compares language tags case-insensitively, but
the canonical encoding wrote the raw tag, so term-equal literals
("a"@en vs "a"@en) encoded differently: SET-mode VALUES deduplication
missed the duplicate and equivalent queries came back UNKNOWN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scan() deduplicated visited nodes with a value-equality HashSet, so a
structurally equal node whose equals-invisible isVariableScopeChange
flag differed was skipped and its correlation sensitivity silently
lost; the visited set is now identity-based. duplicateFree(Extension)
forwarded to its argument even when an ExtensionElem overwrites an
already-bound name, which can collapse distinct rows into duplicates;
overwriting extensions now report duplicateFree=false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two confirmed false-EQUIVALENT soundness holes in the incoming-binding
analysis:

- externalReads(LeftJoin) never collected the OPTIONAL condition's
  variables, so a condition reading a sibling-bound may-bind variable
  (exposed by the well-designed-left-join path) did not block JOIN
  commutation. Condition reads not guaranteed by the left arg now count
  as incoming reads.
- orderIsObservableAcrossOperands modelled reads and discards but not
  re-binding writes: an Extension overwriting a name the sibling operand
  may bind commuted freely although ExtensionIterator clobbers the
  incoming value. IncomingBindingInfo now carries a rebinds set,
  collected from ExtensionElem targets and propagated through all
  combinators, and rebind/may-bind overlap makes order observable.

The two pinned counterexamples flip from documenting the defect to
asserting NOT_EQUIVALENT with a verified witness; the test class is
renamed JsonTupleExprSoundnessRegressionTest accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isOuterProjection() walked getParentNode() without a boundary, so
equivalence verdicts depended on ancestors OUTSIDE the two compared
expressions: the same join pair flipped from NOT_EQUIVALENT (detached)
to a false EQUIVALENT when both sides were still attached under a live
outer Projection, contradicting the module's own evaluation oracle,
which always evaluates detached clones. The analysis root (the
expression handed to normalize()/externalReads()/
containsRuntimeCorrelation()) is now threaded through the analyzer and
the parent walk stops there; canonicalizer-built detached fragments use
themselves as the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The filter-into-LeftJoin push rebuilt the LeftJoin with
scopeBoundary=false regardless of the node's own flag, so two trees
differing only in the LeftJoin's variableScopeChange flag canonicalized
identically and were proved EQUIVALENT whenever a relocatable filter
sat above them — although the flag steers evaluation (incoming-binding
exposure) and every other canonicalization path preserves it. The push
now refuses scope-boundary LeftJoins; the sibling union-distribution
recursions were already unreachable for boundary nodes via the ordered
early return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seeded random TupleExpr generator over the checker's supported grammar,
a 16-mutator catalog (semantics-preserving and -breaking), scope-safety
corpus and legacy-optimizer pair sources, and a differential oracle
holding every pair to five invariants: EQUIVALENT verdicts must survive
the checker's own BoundedCounterexampleFinder (mandatory because
check() short-circuits on the proof path), NOT_EQUIVALENT witnesses
must replay stably, check() must not throw, verdicts must be
deterministic and order-coherent, and JSON round-trips must preserve
tree equality and the verdict. FuzzSmokeTest runs deterministically in
every build; FuzzSoakTest (@tag slow) takes equivalence.fuzz.* system
properties, shrinks failures and persists reproducible artifacts under
target/equivalence-fuzz/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the equivalence differential fuzzer: ExtensionIterator
installs a null-binding placeholder when a BIND errors, which reads as
unbound via getValue(), so BindingSetAssignmentQueryEvaluationStep's
compatibility check correctly concludes a VALUES row may bind the name
— but ArrayBindingSet.getDirectAddBinding asserted the slot was never
written and crashed any assertion-enabled run (plain builds already
performed the overwrite, which matches SPARQL join semantics). The
assert now tolerates the placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the equivalence differential fuzzer: ProjectionElemList
implements VariableScopeChange, but the JSON format serializes the
element list as a bare array, so a set flag was silently dropped by
parse(write(tree)) and the round-tripped tree was no longer
ExactTreeEquality-equal. Projection now carries an additive
projectionElementsScopeChange field; MultiProjection, which has no
representation for a per-list flag, fails the write loudly instead of
round-tripping to a semantically different tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ession manifest

CheckerCompletenessGuardTest regenerates a fixed 40-pair set (seed
0xC0FFEE) and compares every verdict against fuzz/completeness-ledger.csv
— a soundness fix that legitimately degrades a proof to UNKNOWN now
forces a visible one-line ledger diff instead of slipping through;
aggregate floors catch outright coverage collapse. Each run rewrites
target/completeness-ledger-bootstrap.csv for easy re-pinning.
JsonTupleExprFuzzRegressionTest is manifest-driven: promoted fuzz
artifacts (first entry: the shrunk VALUES-over-null-binding crash pair)
are re-checked on every build for both robustness and their pinned
verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the equivalence differential fuzzer: when incoming bindings
overlap the VALUES names, the compatibility-checked path only created a
result once it saw a row NAME, so the empty mapping was dropped
entirely — while an equal row that carried its UNDEF columns as
name-with-null (a parser-built ListBindingSet) passed through. Two
equal trees evaluated to different results depending on which
representation their rows used. Empty and all-UNDEF rows now join as
the identity mapping on every path, and raw rows returned by the
no-incoming-bindings fast path are stripped of declared-but-UNDEF
columns so the representation difference can never leak downstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A declared-but-UNDEF column stored as a name with a null value binds
nothing, so it must not count toward a VALUES node's possible or
assured binding names — otherwise equal rows that merely represent
UNDEF differently derive different names. BindingSetAssignment and the
equivalence module's BindingAnalyzer now ignore null-valued columns.
SemanticsValidator additionally rejects a BindingSetAssignment whose
rows were never set: evaluating such a node fails at precompile time,
so no algebraic proof about it can be checked against runtime behaviour
(the fuzzer produced EQUIVALENT verdicts for trees whose runtime
behaviour was an exception). Completeness ledger re-pinned for the one
intentional NOT_EQUIVALENT-to-UNKNOWN move this causes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the equivalence differential fuzzer: a Var carrying a value
but NOT flagged constant is a real variable at runtime — the statement
pattern step uses the value as a constraint AND binds the name into the
result row (only isConstant() vars are skipped). The binding analyzers
filtered on hasValue(), so such variables vanished from may/must-bind
domains: MINUS_DISJOINT_DOMAINS erased a MINUS right side whose rows
actually shared a runtime binding name with the left side, proving
trees EQUIVALENT that a witness dataset distinguishes. BindingAnalyzer
and the incoming-binding fallback collector now discriminate on
isConstant(); ExpressionVariables stays on hasValue() because value
expressions evaluate the embedded value, not the binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The license header URL was missing the /org/ path segment, which fails
the copyright-check CI job (scripts/checkCopyrightPresent.sh requires
the exact http://www.eclipse.org/org/documents/edl-v10.php line).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-review follow-ups to the declared-vs-derived binding-name split:
ArrayBindingBasedQueryEvaluationContext wrote the derived names back
through setBindingNames, which now sets the DECLARED header — so merely
precompiling a query silently dropped all-UNDEF declared columns from
the model (the write-back was an interning optimization; getIndex has
an equals fallback, so only the interning read remains). The SPARQL and
SPIN renderers now prefer the declared header so a column that is UNDEF
in every row is not dropped from rendered output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .agent working document belongs to a different workstream and has
no place in the algebra-equivalence PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-review follow-ups: FuzzSoakTest runs in the pr-verify slow-tests
job, so its default seed is now fixed (a nanoTime default would fail
unrelated PRs nondeterministically; pass equivalence.fuzz.seed for
exploration); the optimizer pair source uses insertion order instead of
per-JVM-salted Map.of iteration so seed-pinned repro commands replay
the same pair schedule; the smoke test writes artifacts per seed and
fails loudly if the time budget truncates the battery; the soak asserts
the corpus actually produced pairs and reports truncation; the vacuous
NOT_EQUIVALENT floor is gone; the shrinker documents its
same-invariant-not-same-defect limitation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BindingSetAssignment.getBindingNames() now derives names from the
actual rows, so the zero-row 'VALUES (?a) {}' placeholders in SHACL's
bulked validation queries report an empty name set and all three
injection sites silently skipped setBindingSets — transactional
validation then ran against an empty VALUES and under-reported
violations that span transactions (e.g. a maxCount violation whose
prior value was committed earlier). The injection sites now match on
getDeclaredBindingNames(), and the experimental
PreprocessedQuerySerializer prefers the declared header so zero-row or
all-UNDEF columns are not dropped from rendered output. The new
BulkValuesInjectionTest pins the cross-transaction bulk path end to
end (red before this fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-query configuration read is now memoized on the raw property
strings (re-parsed only when a property actually changes) and never
throws: a malformed org.eclipse.rdf4j.query.scopeSafety.* value logs
one warning per distinct bad tuple and disables the features, instead
of failing every query in the JVM from inside optimize(). read(Function)
stays strict for direct callers and tests. Also introduces the
shadowStrict property (default false) consumed by the next commit's
shadow-containment work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHADOW is an observation mode, so it must never change what the user
gets back: candidate-plan exceptions (at optimization AND evaluation
time) and result mismatches are now recorded as telemetry and the
already-materialized legacy rows are returned; the new shadowStrict
property (default false) restores fail-fast behaviour for CI/canary
runs. A candidate that returns more rows than the fully-enumerated
legacy result is recorded as a MISMATCH (it is provable), not a row
limit. Sequence comparisons are only strict when the root's order is
actually guaranteed (an Order reached through order-preserving
operators) — a nested Order/Slice no longer forces bogus sequence
mismatches — and a Slice with no backing Order is skipped as
NONDETERMINISTIC since two valid plans may legitimately return
different row subsets. Sequence divergences with agreeing multisets
count as SHADOW_ORDER_DIVERGENCE instead of mismatches. AUDIT remains
fail-fast by design and both contracts are now documented on
ScopeSafetyMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode-dispatch bridge was copy-pasted across seven optimizers in two
modules with three diverging shapes; the divergence included an
ENFORCE+non-QueryRoot edge where FilterOptimizer/ProjectionRemoval fell
through to their scope-UNSAFE legacy pass. ContextAwareQueryOptimizer
now carries a single static dispatch with an explicit per-optimizer
EnforcePolicy (SKIP for join reordering/normalization and the LMDB
pair, REPLACE for the filter/projection scope-safe rewrites — never
falling through on fragments, now pinned by a test — and RUN_LEGACY for
the scope-safe union scope-change relaxation). AUDIT keeps the eager
refresh (it IS the audit mechanism); SHADOW's legacy leg no longer pays
an eager ScopeAnalysis rebuild per optimizer that nothing consumed.
OptimizationSession.afterLegacyOptimizer drops its unused Class
parameter; ScopeSafeRewritePass.projections() gains the convergence cap
the other drivers already had; and the TransactionalTreeEditor
move/factory API, unused by every rewrite, is deleted (rollback keeps
the install-reversal core).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parser now sets the subquery flag accurately (nested sub-selects
true, the top-level projection false) and optimizers introduce
flag-false projections as deliberately TRANSPARENT correlated nodes, so
TupleExprs.containsSubquery must keep honouring the flag — a positional
rule was prototyped and refuted by the spec-verified scope-safety
fixtures (q03 lost a row in ENFORCE because an internal transparent
projection was suddenly treated as an isolation boundary). The residual
obligation is documented and pinned instead: code that embeds a parsed
top-level projection as a join operand is turning it into a subquery
and must call setSubquery(true) to get isolation semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hmottestad and others added 16 commits July 16, 2026 22:10
The rdf4j-queryalgebra-json package is now @experimental like its
sibling equivalence module, so japicmp does not semver-lock the new API
at its first release. TupleExprJsonParser's JsonNode entry point gains
the decoder-level depth guard the stream entry points already had via
StreamReadConstraints — a hostile-depth prebuilt JsonNode now fails
with a TupleExprJsonParseException instead of a StackOverflowError.
The equivalence README documents the scope-safety modes, including the
ENFORCE optimization loss (join reordering and normalization are
skipped) and SHADOW's lenient-by-default containment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BindingSetAssignment.clone() carries the derived binding-name caches
(they depend solely on the shared immutable row reference), removing an
O(#rows) rebuild on every per-execution clone of a VALUES query.
SHADOW sampling decides BEFORE cloning the tree — at low rates the
whole-tree clone was built and discarded for almost every query — and
uses ThreadLocalRandom instead of a structural hash that was both
badly skewed and deterministic per query. The ProofKernel's re-derivation
of freshly computed proofs (which doubled the cost of every successful
check) moves behind CheckOptions.deepProofVerification, default off;
the differential fuzzer keeps it on. The three scope-safe rewrite
passes now share one fixpoint driver instead of three diverging copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SemanticFingerprint/Fingerprinter/FingerprintIndex, SemanticAnalyzer(Impl),
SemanticSummary and CardinalityBounds were unreachable from check(), and
BindingShapeOracle, SemanticLivenessAnalyzer and RewriteRuleId had no
production callers. Their tests pinned only the deleted code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The seed now records only frames, regions, environments, symbol
name/frame, origins and their occurrence symbols. Fingerprint, physical
names, symbol visibility, occurrence roles/ordinals and the whole
boundary/export/allowed-input section had no production consumer;
dropping them also removes their per-parse recording cost in
QueryScopeSeedRecorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BFS in TupleExprs.containsSubquery returned false as soon as it
dequeued a Join, ending the scan before any queued Union siblings were
examined. Join and LeftJoin evaluation use this helper to decide whether
the right-hand side needs subquery scope isolation, so the decision — and
the query results — depended on Union branch order: LeftJoin(x, Union(
subselect, Join(..))) isolated the subselect while the commuted
LeftJoin(x, Union(Join(..), subselect)) pushed bindings into it.

Found by the differential fuzzer (soak seed 71601, pair seed 73613): the
checker correctly proved a union-commute pair EQUIVALENT but the runtime
returned different bags. The bug predates this branch. A Join operand
stays opaque (its own evaluation owns the projections inside it), but the
scan now continues with the remaining siblings. The shrunk pair is pinned
in the fuzz-regressions manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the differential fuzzer (soak seed 71603, pair seed 73681):
commuting a Join whose operand contained a Distinct changed the result
multiset. Pushing a sibling's bindings into a Distinct/Reduced argument
that can produce solutions with differing domains collapses solutions
that differ only in an unbound variable, and a Slice truncates filtered
instead of unfiltered solutions — neither matches bottom-up semantics.

Join and LeftJoin evaluation now isolate operands flagged by the new
TupleExprs.containsResultSetModifier. Uniform-domain Distinct/Reduced
(every possible name assuredly bound, e.g. the parser's path? encoding,
whose ZeroLengthPath depends on correlation) stay on the push-down path,
which is injective and therefore stable for them.

Isolation is only correct if the isolation join is: HashJoinIteration
hashed on may-bind names, silently dropping rows whose join attribute
was unbound on one side, and its merge never checked value equality on
shared non-attribute names. It now hashes on assured-on-both-sides names
and verifies full SPARQL compatibility per bucket row. A conditional
LeftJoin on the isolation path used to drop its condition entirely (also
for pre-existing subquery right-hand sides); the new ScopedLeftJoinIterator
materializes the right-hand side once and applies compatibility plus the
condition per left-hand row.

The shrunk pair is pinned as fuzz-regressions/distinct-pushdown-join-commute,
and the manifest test now re-runs the full differential oracle on every
pinned pair under all three profiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CartesianJoinExplainAnalyzer treated a transparent projection's whole
element list as a mandatory-region atom. Since the parser started
marking the top-level projection subquery=false (making it transparent
to the region walk), that atom mentioned every output variable and
spuriously connected all patterns under any top-level SELECT, so real
Cartesian products lost their explain annotation
(QueryPlanRetrievalTest.testExplainAnnotationsEmitDisconnectedJoinTypeTextAndJson).
A projection element only correlates variables when it renames
(?name AS ?alias); identity elements now contribute no region atom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RDF4J eagerly precompiles both operands and the condition of a LeftJoin
before either input is evaluated, so eliding one can hide a deterministic
preparation failure. An invalid constant regex in a LeftJoin condition
fails even when the right input is empty, which means folding
LeftJoin(x, EmptySet, badCondition) to x turns a runtime error into a
result.

Demote LEFT_JOIN_EMPTY_LEFT, LEFT_JOIN_EMPTY_RIGHT, LEFT_JOIN_UNIT_RIGHT
and MINUS_EMPTY_LEFT to specification-only rules: the Lean constructors
gain an explicit notRuntime premise, and CertifiedRuleRegistry and
Rdf4jCanonicalizer enforce the same restriction on the Java side. The
rules remain theorem-backed for SPARQL_1_1, SPARQL_1_2 and
BOTH_1_1_AND_1_2. The rule/profile matrix moves from 347/677 to 323/701
theorem/inapplicable cells.

Adds JsonTupleExprSoundnessRegressionTest#
eagerConditionCompilationBlocksLeftJoinEmptyRightFold and
CertificateReplayNormalizerTest#
rejectsRuntimeRulesThatElideEagerlyPrecompiledSubtrees as regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous commit blocked the four elision rules under RDF4J_RUNTIME by
adding a notRuntime premise to the Lean constructors. That fixed the
symptom from outside the model: referenceEval still defined

    leftJoin left .empty condition  =  referenceEval left

for every target, discarding the condition, so the model kept asserting an
equivalence that is false of RDF4J. The restriction was a policy bolted on
top of a semantics that could not express the defect, because
ReferenceEnvironment.evalCondition returns a total Bool and preparation
failure is unrepresentable.

Give the model the missing phase. ReferenceEnvironment gains
conditionPrepares, and conditionPrepared applies it for RDF4J_RUNTIME only:
that target compiles a condition once before either input is evaluated,
while the specification targets evaluate per solution and cannot fail
preparation. The elision equations now propagate what they discard, so the
four theorems carry ConditionPrepares and RuntimeOperandTotal premises
rather than holding vacuously.

Because the premises are real, they can also be discharged. An absent
condition has nothing to precompile and an EmptySet/SingletonSet operand
cannot fail, so checkStep decides elisionFree syntactically and the rules
stay available at runtime exactly when the elision is observationally free.
This recovers the completeness the target gate gave up: LeftJoin(x, EmptySet)
with no condition is EQUIVALENT again under RDF4J_RUNTIME, where the
previous commit returned UNKNOWN. The matrix returns to 347/677, now backed
by discharged premises instead of a universally quantified falsehood.

CertifiedRuleRegistry mirrors the premise as permitsConditionElision and
permitsOperandElision; Rdf4jCanonicalizer and LocalCertificateReplayer
consult them where the rule is applied. The same guard closes the
previously unprotected Filter-over-EmptySet fold, which discarded a
condition with no rule recorded.

The finite oracle can now witness a preparation failure, so the sweep can
refute a bad elision certificate rather than being blind to the class.
Adds reject-runtime-condition-elision and valid-runtime-elision-free
fixtures covering both directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant