Repository navigation
fix(provenance): prove bound recursive queries from Magic Sets' evaluated tuples - #354
Merged
Merged
Conversation
…ated tuples A bound recursive `.why` (`.why ?reach(1, Y)`) was evaluated by Magic Sets under the adorned relation `reach_bf`, so proof search found no `reach` tuples in the derived data. Every subgoal on the recursive relation then fell through to forward enumeration from the rules, which re-derives the closure with no memoization and explores every path around a cycle down to the depth limit: 1.7 s for the perf-gate engine graph (200 nodes, 300 edges, 4 rows), 32 s for a six-edge graph with one cycle, and no answer after 10 minutes on 15 nodes and 30 edges. - The engine's derived relations now also name each adorned relation's tuples by the relation it restricts, unless the program evaluated that relation unrestricted too. One adornment shares its tuples; several are merged without duplicates. - Proof search no longer re-derives a derived relation the evaluation computed, or one that is materialized: an empty lookup there means the subgoal has no derivation. Proof rows and root proof DAGs are unchanged on a corpus of cyclic, left-, right- and non-linear recursive, same-generation, mutually recursive, negation-over-recursion and random-graph queries. The perf-gate case drops from 1.74 s to 1.2 ms (the query alone takes 1.0 ms). Adds a handler test proving bound recursive queries around a cycle down to `edge` facts, engine tests for the exposed adorned relations, a proof search test for computed relations, and a `why_recursive_cyclic` benchmark group.
…rom proof context
jsam
force-pushed
the
fm/fix-why-recursive
branch
from
October 5, 2026 14:18
be79305 to
0253839
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. (Context: the first engine baseline on the dedicated benchmark host, main 25058f1, perf-gate/baselines/engine-baseline-2026-10-04.md on branch fm/bench-machine, shows
.why ?reach(1, Y)(a recursive rule, 4 result rows, 200 nodes / 300 edges) takes 1.67 s p50, while.whyon a non-recursive rule takes 3.4 ms. Proofs are the core of 'decisions your agents can prove'.)What Changed
After a Magic Sets rewrite, the engine now lists the adorned relations it created (e.g.
reach_bf→reach, via the newMagicSetRewriter::adorned_relations). When execution finishes, it copies each adorned relation's tuples into the derived results under the original relation's name, so proof search can look them up. If a relation has more than one adornment, its tuples are merged without duplicates. If the program also evaluated the relation unrestricted, that full result is left as is.Proof search no longer re-derives a subgoal from the rules when the evaluation already computed every tuple of that derived relation (
ProofContext::is_complete). If such a subgoal has no matching tuple, it fails right away. Before,.whyon a left-recursive closure (e.g..why ?reach(1, Y)) kept re-deriving the closure around every cycle in the graph.Added tests:
prove_bodytest showing a computed relation isn't re-derived;.why/.why fullon left- and right-recursive paths over a cyclic graph return the expected rows, with proofs that go all the way down toedgefacts.Also added a
why_recursive_cycliccriterion benchmark: the 200-node / 300-edge random graph and a 30-node ring.Risk Assessment
✅ Low: The change is well-bounded. Adorned Magic Sets tuples are exposed under the original name only when the program did not also evaluate that relation unrestricted. Because adornments bind only positions that stay invariant across recursion, the demanded subset covers every subgoal proof search reaches. Truncation by the result cap only hits the query head, and the
.whypath turns off constant specialization, sois_completeholds for every derived relation the proof context sees. The prior round's removal ofmaterializedis fully applied.Testing
I followed the operator's budget limits: the targeted provenance, Magic Sets and proof lib tests (all 138 passed) and the why_recursive_cyclic benchmark. I did not build the base commit and did not run the whole workspace. I then built the release server and client at HEAD and ran them against a throwaway data directory, sending live queries over the WebSocket API. On the perf-gate graph,
.why ?reach(1, Y)returned the same 4 rows as the plain query in 2.1–2.3 ms per call, against the 1.67 s baseline, and every proof was complete down toedgefacts. On the 6-edge cycle, the bound recursive.whyvariants each returned in about 2 ms, fully grounded. A query for a row that does not exist returned an empty result quickly..why_notstill names the correct blocker for each rule. In the benchmark, random_200_300 took about 0.91 ms and ring_30 (a 30-node ring where every node reaches every other) about 22.6 ms. I removed the transient binaries and a stale server and temp directory left over from the previous run..why ?reach(1, Y)on the perf-gate 200-node/300-edge recursive graph and gets proofs in milliseconds instead of 1.67 s?reach(1, Y).why ?path(1, Y)and.why ?path(2, 2)around a 3-cycle (previously 27 s and 12 s) return quickly and correctly.whyfor a recursive tuple that does not exist (path(4,1)) returns no rows and no false proof.why ?path(4, 1)returned 0 rows in 1.4 ms.why_noton recursive relations still names the true blocker for each rule after the proof-context change.why_not path(4, 1)blames cyc(4,1) and cyc(5,1);.why_not reach(1, 2)blames edge(1,2) and edge(137,2)Evidence: Live .why transcript at HEAD (200/300 reach + cyclic path/rpath)
Evidence: Live .why_not transcript at HEAD
Evidence: Targeted release lib tests log
Evidence: Criterion why_recursive_cyclic results
Source: Criterion why_recursive_cyclic results (local file:
~/.no-mistakes/evidence/01M462DB7ZG5ZHMXCCNVBTHPCY/bench-why_recursive_cyclic.log)Evidence: Live .why ?reach(1, Y) excerpt
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ No issues found.
src/protocol/handler.rs:808- This is a component the intent doesn't need. Besides the derived-data completeness check, the change adds a second completeness source:ProofContext::materialized,with_materialized(src/provenance/backward_chaining.rs:46, :84) and thematerialized.containsbranch ofis_complete(backward_chaining.rs:109), wired up fromsnapshot.materialized_relationshere. In production that set is always empty. Materializations come only fromenable_incremental/set_auto_materialize/materialize_derived_relation, and nothing outside tests calls any of them. The slow.why ?reach(1, Y)case is fixed entirely byexpose_adorned_relationsplus the derived-data half ofis_complete. The new branch only runs in tests (the third case of prove_body.rstest_computed_relation_is_not_re_derived). The.why_notcontext at handler.rs:919-924 never sets it, so the two proof paths treat materialized relations differently. Suggested fix: remove thematerializedfield,with_materialized, its call here and that test case, and keepis_completebased on derived data only. If you want this for future materialization support, keep it, but then also wire it into.why_not.🔧 Fix applied.
✅ Re-checked - no issues remain.
🔧 **Test** - 1 issue found → no changes applied ✅
src/provenance/backward_chaining.rs:338- Not a regression, but it bears on the 'production-ready proofs' goal: on cyclic data, backward chaining still sometimes ends a proof in asource: derivedfact leaf instead of tracing that tuple back to edge facts. Examples:.why ?path(X, Y)row path(3,5) goes through an untraced path(3,3); on the 15-node graph, reach(1,8) and reach(1,13) are untraced. The base build gives the same leaves for unbound queries, and this change makes bound queries match those unbound proofs exactly. The likely cause is the visited-set fallback insrc/provenance/backward_chaining.rs(~line 338), which falls back to aDerivedleaf once ancestors block every rule derivation. Fixing it would be a follow-up, outside this change..why ?reach(1, Y)on the perf-gate engine graph (200 nodes/300 edges) returns 4 grounded proofs in milliseconds instead of ~1.7 s.whyaround a cycle (left- and right-linear,full, fully bound) gives correct rows and proofs grounded in edge facts.why ?path(1, Y)26.8 s -> 2 ms,.why ?path(2, 2)12 s -> 1.3 ms; rows and root proofs identical; all grounded.why ?path(4, 1),?reach(5, Y)) returns no rows quickly rather than re-deriving the closure.why ?path(1, Y)drops node 4 and no proof uses the retracted edge.why ?path(X, Y)/?reach(X, Y)(no Magic Sets) gives the same proofs as beforecargo build --release --bin inputlayer-server --bin inputlayer-clientat be793058, plus a base ddcd1584 build fromgit archive, each server running on its own port, temp data dir and HOMELoaded fixtures withinputlayer-client --script: a 6-edge cycle with left- and right-linear rules, the perf-gate graph Graph::random(200,300,42) regenerated with the same SplitMix64, a 15-node/30-edge random graph, and a mixed KG (non-linear reach, mutually recursive even/odd, negation over recursion)WebSocket client (Node 22) sending.whyprograms and capturing raw proof_trees and wall-clock timings; a checker that requires one tree per row, each root equal to its row, and every leaf a real EDBedgefact.why ?reach(1, Y)x21 on the new build vs x5 on the base, plus.why ?two_hop(1, Z)and plain?reach(1, Y)for reference.why ?path(1, Y),.why full ?path(1, Y),.why ?path(2, 2),.why ?rpath(1, Y),.why ?rpath(X, 5),.why ?path(X, Y)and the no-proof.why ?path(4, 1)on both buildsCompared rows and canonical root proof trees between base and new buildsRetractededge(3,4)live, then ran.why ?path(1, Y),?path(1,4)and?path(3,Y)and checked the proofs against the reduced EDBcargo test --release --lib -- recursive_proof_tests test_derived_exposes test_derived_keeps test_expose_adorned test_computed_relation_is_not_re_derived🔧 No changes applied.
✅ Re-checked - no issues remain.
.why ?reach(1, Y)on the perf-gate 200-node/300-edge recursive graph and gets proofs in milliseconds instead of 1.67 s?reach(1, Y).why ?path(1, Y)and.why ?path(2, 2)around a 3-cycle (previously 27 s and 12 s) return quickly and correctly.whyfor a recursive tuple that does not exist (path(4,1)) returns no rows and no false proof.why ?path(4, 1)returned 0 rows in 1.4 ms.why_noton recursive relations still names the true blocker for each rule after the proof-context change.why_not path(4, 1)blames cyc(4,1) and cyc(5,1);.why_not reach(1, 2)blames edge(1,2) and edge(137,2)cargo test --release --lib -- provenance magic_sets recursive_proof proof_snapshot pinned_proof test_derived test_expose(138 passed, includes recursive_proof_tests::why_proves_bound_recursive_queries_around_a_cycle)cargo build --release --bin inputlayer-server --bin inputlayer-clientat HEAD 0253839e, then drive.sh: real inputlayer-server on 127.0.0.1:18391 with a temp data dir and bootstrap API key, seeded viainputlayer-client --script, queried over the WebSocket APILive:?reach(1, Y)plus 5×.why ?reach(1, Y)on the 200-node/300-edge seed-42 graphLive:.why ?path(1, Y),.why full ?path(1, Y),.why ?path(2, 2),.why ?rpath(1, Y),.why ?path(4, 1)on the 6-edge cyclic graphLive:.why_not path(4, 1)and.why_not reach(1, 2)(port 18392)cargo bench --bench proof_benchmarks -- why_recursive_cyclic✅ **Document** - passed
✅ No issues found.
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.