Repository navigation
Engine: multi-clause rules silently return empty or malformed results when clauses share a derived relation #91
Description
Summary
Some multi-clause rules evaluate to an empty relation with no error, and one variant returns rows with the wrong number of columns. Single-clause versions of the same rules work. Found while validating the Verified Completions rule pack (#81) on the real engine: the pack's 15-clause conflict relation silently returned zero rows while sibling relations (violation, tension) worked.
This is a wrong-results bug, not a crash: nothing is logged, the rules register fine, and the query just returns "No results."
Repro 1: union goes silently empty
.kg create bug_union_empty
.kg use bug_union_empty
+claim(id: string, entity: string, attr: string, val: string)
+claim_modality(id: string, modality: string)
+link(x: string, y: string)
+claim[("c1", "e1", "attr", "v1"), ("c2", "e2", "attr", "v2"), ("c5", "anna_1", "distinct_from", "anna_2")]
+claim_modality[("c1", "asserted"), ("c2", "asserted"), ("c5", "asserted")]
+link[("e1", "e2")]
+active(C, E, A, V) <- claim(C, E, A, V), claim_modality(C, "asserted")
+r("bridge", C1, C2) <- active(C1, E1, A1, V1), active(C2, E2, A2, V2), link(E1, E2)
+r("solo", C, C) <- active(C, E1, "distinct_from", E2)
?r(K, C1, C2)
Expected: ("bridge","c1","c2") and ("solo","c5","c5"). Actual: No results.
Register only the bridge clause and it returns its row. Register only the solo clause and it returns its row. Together: empty. Order does not matter, and the poisoning is retroactive: if you query between registrations, the first clause's rows appear, then adding the second clause makes the whole relation empty.
Repro 2: malformed output (wrong arity)
.kg create bug_union_arity
.kg use bug_union_arity
+ca(c: string, e: string)
+ca[("c1", "e1"), ("c2", "e1")]
+a(C, E) <- ca(C, E)
+r("joined", C1, C2) <- a(C1, E), a(C2, E), C1 != C2
+r("solo", C, C) <- a(C, E)
?r(K, C1, C2)
Expected: the joined pair plus solo rows, 3 columns. Actual: 8 rows with 4 columns (col0..col3), which looks like an unreduced intermediate cross product leaking out as the query result.
What we narrowed down (each line is a tested variant)
Works:
- Two clauses over base relations only, any shape, any order
- Two clauses over the same derived relation when the solo clause has no constant in its body atom (
r("solo", C, C) <- active(C, E, A, V)) - Bridge-join clause over a derived relation + solo clause over a DIFFERENT relation
- Derived relation defined via projection or via join - both fine in the working combos
- Three clauses over 2-column derived + base relations (projection case)
Fails:
- Repro 1: bridge-join clause + solo clause with a string constant in a body atom, both over the same 4-column derived relation (silently empty)
- Same shape with the bridge as
coentity,same_as, or a baselinkrelation (silently empty) - Repro 2: self-join +
!=on head variables + solo clause, 2-column derived relation (malformed 4-column output)
Suspects worth checking: union plan compilation when clauses share a derived relation subplan with different binding patterns (subplan sharing / code generator), and constant-filter pushdown inside one branch of a union.
Impact
Any user-written multi-clause rule can silently return wrong (empty or malformed) results depending on body shape. For a reasoning engine this is the worst failure mode: no error, plausible-looking absence of results.
Workaround (verified): keep one clause per derived relation and union only at a final reporting layer where every clause references a distinct relation. The Verified Completions pack is being restructured this way in #81.
Repro scripts are deterministic and take under a second each; happy to add them as .iql snapshot tests in a category like examples/iql/12_errors or a new regression category once the fix lands.
Activity
jsam commentedon Jul 26, 2026
Additional data point: the bug is NOT in the optimizer passes. Repro 1 still returns empty with all three flags disabled in config:
Same result as with defaults. So the problem is in core rule/union compilation or the code generator, not in join planning, SIP rewriting, or subplan sharing.
feat: Verified Completions design package, engine-validated rule pack…
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, executable benchmark, …
feat: Verified Completions - proven core loop, verified benchmark cor…
feat: Verified Completions - proven core loop, verified benchmark cor…
feat: Verified Completions - proven core loop, verified benchmark cor…
feat: Verified Completions - proven core loop, verified benchmark cor…
feat: Verified Completions - proven core loop, verified benchmark cor…
feat: Verified Completions - proven core loop, verified benchmark cor…
chore: Verified Completions checks a system prompt against its runtime (
fix(engine): multi-clause rules sharing a derived relation (#91) (#123)