Skip to content

fix(provenance): stop .why_not refusing derived rows and report the actual blocker - #353

Merged
jsam merged 2 commits into
mainfrom
fm/fix-why-not-const-head
Oct 5, 2026
Merged

jsam merged 2 commits into
mainfrom
fm/fix-why-not-const-head

Conversation

@jsam

@jsam jsam commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Intent

I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. (Context: issue #291: .why_not mis-evaluates body atoms over a view whose rules have constants in the head, reporting a derived row as NOT derived, and .why_not on such a view fails with 'Head unification failed: Target arity 2 does not match head arity 2'. Proof and refusal explanations are the core of 'Decisions your agents can prove'.)

What Changed

  • .why_not now evaluates the target relation in full, with no result cap, and reads the derived relations as well as base facts. A row that is actually derived is reported as derived, with the same proof .why gives. A row is reported as NOT derived only when ?relation(values) returns nothing. This fixes body atoms over views whose rule heads contain constants (.why_not mis-evaluates views whose rules have constants in the head, reporting derived rows as not derived #291).
  • For each clause, a backtracking search over base facts and derived relations finds the attempt that gets furthest. It shows the premises that held and the premise that failed for those bindings. Head mismatches now give their actual cause: a different value count, the column whose constant differs, or a computed column (N+1, count<X>) that evaluates to a different value. Previously all of these produced the misleading "Target arity 2 does not match head arity 2". There is also a new not_explained blocker for function calls, nearest-neighbour searches and searches that hit their limit, so these no longer produce an invented failure.
  • .why and .why_not now evaluate body predicates in the same order. Comparisons wait until their variables are bound, equalities assign variables, and negations wait for every predicate that can bind their variables. Negated atoms read derived relations, and .why can prove rules that have computed head columns. The explainability and commands docs (and the GUI docs bundle) are updated, and regression and property tests are added in src/protocol/handler/why_not_tests.rs.

Risk Assessment

⚠️ Medium: The change is medium risk. It rewrites the core .why_not explainer and changes the shared prove_body evaluation order that .why also uses. I traced the main paths (constant and computed heads, negation over derived relations, assignments, aggregates) and found them correct; the only notes are informational: a cut-short search isn't disclosed, and each .why_not now runs a full uncapped evaluation.

Testing

I built the fixed commit and the base commit and ran both as live servers, each with its own throwaway data directory. I drove them with the real client and with a small WebSocket driver that captures the full explanation text. On the fixed build, every scenario passes: - Issue repro: both derived rows are reported as derived, with proofs. - True blocker: the blocked row names its real cause, the negated blocked fact. - Arity message: the "Target arity 2 does not match head arity 2" message is gone. It is replaced by the specific mismatch, either the differing constant or the wrong value count. - Other head shapes: mixed constant/variable heads, computed heads and recursive views all give correct results. - Sweep: across 24 tuples, .why_not agrees with the query every time. The base commit reproduces the bug (16 of 24 tuples disagree, plus the arity message). The targeted why_not unit tests also pass. There is no UI surface, so the evidence is CLI and WebSocket transcripts. Servers, temp directories and build outputs were removed afterwards.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Issue repro: .why_not on allowed("s-43","open_ticket"), a row ?allowed returns, reports it as derived with a proof through the constant-head candidate view ✅ pass live issue291_after_full.txt; base: issue291_before_base.txt reports 'was NOT derived ... No matching tuples in candidate'
.why_not on the constant-head view itself, candidate("s-43","issue_refund"), reports it as derived instead of the 'Target arity 2 does not match head arity 2' error ✅ pass live issue291_after_full.txt; base shows the arity-2 error
Real refusal names the true blocker: .why_not allowed("s-43","issue_refund") blames the negated blocked fact, not a missing candidate tuple ✅ pass live issue291_after_full.txt: 'Blocker: Negated blocked("s-43", "issue_refund") exists'
Adversarial: rows not derived from a constant-head view (wrong constant, unknown session) name the differing head constant or the failing body atom, never a bogus arity message ✅ pass live issue291_after_full.txt (candidate s-44/s-99/close cases)
Adversarial: a target with the wrong number of values gets a plain count-mismatch message ✅ pass live adversarial_after.txt: 'target has 1 values, rule head has 2'
Adversarial: mixed constant/variable heads with a filter, computed heads (N+1), recursive path, and a constant-head view over a recursive view give .why_not verdicts that match the query ✅ pass live adversarial_after.txt
Sweep: across all 24 candidate/allowed tuples, .why_not says derived exactly when ?query returns a row ✅ pass live sweep_after.txt agree=24 disagree=0 (base sweep_before_base.txt: agree=8 disagree=16)
.why on a derived row through the constant-head view returns a full proof tree (rule -> rule -> base fact + negation) ✅ pass live why_proof_after.json
Evidence: Issue #291 script on the fixed server (full explanation text)

>>> .kg create support291b
    [error] {"type":"error","id":"1","message":"Create failed: Knowledge graph already exists: support291b","code":"conflict"}

>>> .kg use support291b
    Switched to knowledge graph: support291b

>>> +ticket[("s-43", "refund"), ("s-44", "question")]
    Inserted 0 fact(s) into 'ticket'.

>>> +blocked[("s-43", "issue_refund")]
    Inserted 0 fact(s) into 'blocked'.

>>> +candidate(S, "issue_refund") <- ticket(S, "refund")
    Rule 'candidate' registered.

>>> +candidate(S, "open_ticket") <- ticket(S, _)
    Rule 'candidate' registered.

>>> +candidate(S, "escalate") <- ticket(S, _)
    Rule 'candidate' registered.

>>> +allowed(S, T) <- candidate(S, T), !blocked(S, T)
    Rule 'allowed' registered.

>>> ?candidate("s-43", T)
    s-43 | escalate
    s-43 | issue_refund
    s-43 | open_ticket

>>> ?allowed("s-43", T)
    s-43 | escalate
    s-43 | open_ticket

>>> .why_not allowed("s-43", "open_ticket")
    allowed("s-43", "open_ticket") was derived:
      [rule] allowed("s-43", "open_ticket")
        rule: allowed(S, T) <- candidate(S, T), !blocked(S, T)
        bindings: T="open_ticket", S="s-43"
      [rule] candidate("s-43", "open_ticket")
        rule: candidate(S, "open_ticket") <- ticket(S, _)
        bindings: S="s-43"
      [base] ticket("s-43", "refund")
      [negation] blocked("s-43", "open_ticket")

>>> .why_not candidate("s-43", "issue_refund")
    candidate("s-43", "issue_refund") was derived:
      [rule] candidate("s-43", "issue_refund")
        rule: candidate(S, "issue_refund") <- ticket(S, "refund")
        bindings: S="s-43"
      [base] ticket("s-43", "refund")

>>> .why_not allowed("s-43", "issue_refund")
    allowed("s-43", "issue_refund") was NOT derived:
    
      Rule: allowed (clause 0)
        allowed(S, T) <- candidate(S, T), !blocked(S, T)
        Blocker: Negated blocked("s-43", "issue_refund") exists

>>> .why_not candidate("s-44", "issue_refund")
    candidate("s-44", "issue_refund") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Body predicate 0 (ticket("s-44", "refund")) failed: No matching tuples in ticket
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Head unification failed: column 1: rule head has "open_ticket", target has "issue_refund"
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: column 1: rule head has "escalate", target has "issue_refund"

>>> .why_not candidate("s-99", "open_ticket")
    candidate("s-99", "open_ticket") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Head unification failed: column 1: rule head has "issue_refund", target has "open_ticket"
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Body predicate 0 (ticket("s-99", _)) failed: No matching tuples in ticket
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: column 1: rule head has "escalate", target has "open_ticket"

>>> .why_not candidate("s-43", "close")
    candidate("s-43", "close") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Head unification failed: column 1: rule head has "issue_refund", target has "close"
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Head unification failed: column 1: rule head has "open_ticket", target has "close"
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: column 1: rule head has "escalate", target has "close"

>>> .why ?allowed("s-43", "open_ticket")
    s-43 | open_ticket
Evidence: Same script on the base server (reproduces the bug)

>>> .kg create support291
    Knowledge graph 'support291' created.
    Switched to knowledge graph: support291

>>> .kg use support291
    Switched to knowledge graph: support291

>>> +ticket[("s-43", "refund"), ("s-44", "question")]
    Inserted 2 fact(s) into 'ticket'.

>>> +blocked[("s-43", "issue_refund")]
    Inserted 1 fact(s) into 'blocked'.

>>> +candidate(S, "issue_refund") <- ticket(S, "refund")
    Rule 'candidate' registered.

>>> +candidate(S, "open_ticket") <- ticket(S, _)
    Rule 'candidate' registered.

>>> +candidate(S, "escalate") <- ticket(S, _)
    Rule 'candidate' registered.

>>> +allowed(S, T) <- candidate(S, T), !blocked(S, T)
    Rule 'allowed' registered.

>>> ?candidate("s-43", T)
    s-43 | escalate
    s-43 | issue_refund
    s-43 | open_ticket

>>> ?allowed("s-43", T)
    s-43 | escalate
    s-43 | open_ticket

>>> .why_not allowed("s-43", "open_ticket")
    allowed("s-43", "open_ticket") was NOT derived:
    
      Rule: allowed (clause 0)
        allowed(S, T) <- candidate(S, T), !blocked(S, T)
        Blocker: Body predicate 0 (candidate("s-43", "open_ticket")) failed: No matching tuples in candidate

>>> .why_not candidate("s-43", "issue_refund")
    candidate("s-43", "issue_refund") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2

>>> .why_not allowed("s-43", "issue_refund")
    allowed("s-43", "issue_refund") was NOT derived:
    
      Rule: allowed (clause 0)
        allowed(S, T) <- candidate(S, T), !blocked(S, T)
        Blocker: Body predicate 0 (candidate("s-43", "issue_refund")) failed: No matching tuples in candidate

>>> .why_not candidate("s-44", "issue_refund")
    candidate("s-44", "issue_refund") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Body predicate 0 (ticket("s-44", "refund")) failed: No matching tuples in ticket
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2

>>> .why_not candidate("s-99", "open_ticket")
    candidate("s-99", "open_ticket") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Head unification failed: Target arity 2 does not match head arity 2
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Body predicate 0 (ticket("s-99", _placeholder_0)) failed: No matching tuples in ticket
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2

>>> .why_not candidate("s-43", "close")
    candidate("s-43", "close") was NOT derived:
    
      Rule: candidate (clause 0)
        candidate(S, "issue_refund") <- ticket(S, "refund")
        Blocker: Head unification failed: Target arity 2 does not match head arity 2
    
      Rule: candidate (clause 1)
        candidate(S, "open_ticket") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2
    
      Rule: candidate (clause 2)
        candidate(S, "escalate") <- ticket(S, _)
        Blocker: Head unification failed: Target arity 2 does not match head arity 2

>>> .why ?allowed("s-43", "open_ticket")
    s-43 | open_ticket
Evidence: Issue #291 script via inputlayer-client (REPL rendering, fixed build)
Connecting to server at http://127.0.0.1:18291...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> .kg create support291
Knowledge graph 'support291' created.
Switched to knowledge graph: support291
> .kg use support291
Switched to knowledge graph: support291
> +ticket[("s-43", "refund"), ("s-44", "question")]
Inserted 2 fact(s) into 'ticket'.
> +blocked[("s-43", "issue_refund")]
Inserted 1 fact(s) into 'blocked'.
> +candidate(S, "issue_refund") <- ticket(S, "refund")
Rule 'candidate' registered.
> +candidate(S, "open_ticket") <- ticket(S, _)
Rule 'candidate' registered.
> +candidate(S, "escalate") <- ticket(S, _)
Rule 'candidate' registered.
> +allowed(S, T) <- candidate(S, T), !blocked(S, T)
Rule 'allowed' registered.
> ?candidate("s-43", T)
┌────────┬────────────────┐
│ s-43   │ T              │
├────────┼────────────────┤
│ "s-43" │ "escalate"     │
│ "s-43" │ "issue_refund" │
│ "s-43" │ "open_ticket"  │
└────────┴────────────────┘
3 rows
> ?allowed("s-43", T)
┌────────┬───────────────┐
│ s-43   │ T             │
├────────┼───────────────┤
│ "s-43" │ "escalate"    │
│ "s-43" │ "open_ticket" │
└────────┴───────────────┘
2 rows
> .why_not allowed("s-43", "open_ticket")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "allowed(\"s-43\", \"open_ticket\") was… │
│ "  [rule] allowed(\"s-43\", \"open_tick… │
│ "    rule: allowed(S, T) <- candidate(S… │
│ "    bindings: T=\"open_ticket\", S=\"s… │
│ "  [rule] candidate(\"s-43\", \"open_ti… │
│ "    rule: candidate(S, \"open_ticket\"… │
│ "    bindings: S=\"s-43\""               │
│ "  [base] ticket(\"s-43\", \"refund\")"  │
│ "  [negation] blocked(\"s-43\", \"open_… │
└──────────────────────────────────────────┘
9 rows
> .why_not candidate("s-43", "issue_refund")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "candidate(\"s-43\", \"issue_refund\") … │
│ "  [rule] candidate(\"s-43\", \"issue_r… │
│ "    rule: candidate(S, \"issue_refund\… │
│ "    bindings: S=\"s-43\""               │
│ "  [base] ticket(\"s-43\", \"refund\")"  │
└──────────────────────────────────────────┘
5 rows
> .why_not allowed("s-43", "issue_refund")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "allowed(\"s-43\", \"issue_refund\") wa… │
│ ""                                       │
│ "  Rule: allowed (clause 0)"             │
│ "    allowed(S, T) <- candidate(S, T), … │
│ "    Blocker: Negated blocked(\"s-43\",… │
└──────────────────────────────────────────┘
5 rows
> .why_not candidate("s-44", "issue_refund")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "candidate(\"s-44\", \"issue_refund\") … │
│ ""                                       │
│ "  Rule: candidate (clause 0)"           │
│ "    candidate(S, \"issue_refund\") <- … │
│ "    Blocker: Body predicate 0 (ticket(… │
│ ""                                       │
│ "  Rule: candidate (clause 1)"           │
│ "    candidate(S, \"open_ticket\") <- t… │
│ "    Blocker: Head unification failed: … │
│ ""                                       │
│ "  Rule: candidate (clause 2)"           │
│ "    candidate(S, \"escalate\") <- tick… │
│ "    Blocker: Head unification failed: … │
└──────────────────────────────────────────┘
13 rows
> .why_not candidate("s-99", "open_ticket")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "candidate(\"s-99\", \"open_ticket\") w… │
│ ""                                       │
│ "  Rule: candidate (clause 0)"           │
│ "    candidate(S, \"issue_refund\") <- … │
│ "    Blocker: Head unification failed: … │
│ ""                                       │
│ "  Rule: candidate (clause 1)"           │
│ "    candidate(S, \"open_ticket\") <- t… │
│ "    Blocker: Body predicate 0 (ticket(… │
│ ""                                       │
│ "  Rule: candidate (clause 2)"           │
│ "    candidate(S, \"escalate\") <- tick… │
│ "    Blocker: Head unification failed: … │
└──────────────────────────────────────────┘
13 rows
> .why_not candidate("s-43", "close")
┌──────────────────────────────────────────┐
│ explanation                              │
├──────────────────────────────────────────┤
│ "candidate(\"s-43\", \"close\") was NOT… │
│ ""                                       │
│ "  Rule: candidate (clause 0)"           │
│ "    candidate(S, \"issue_refund\") <- … │
│ "    Blocker: Head unification failed: … │
│ ""                                       │
│ "  Rule: candidate (clause 1)"           │
│ "    candidate(S, \"open_ticket\") <- t… │
│ "    Blocker: Head unification failed: … │
│ ""                                       │
│ "  Rule: candidate (clause 2)"           │
│ "    candidate(S, \"escalate\") <- tick… │
│ "    Blocker: Head unification failed: … │
└──────────────────────────────────────────┘
13 rows
> .why ?allowed("s-43", "open_ticket")
┌────────┬───────────────┐
│ _c0    │ _c1           │
├────────┼───────────────┤
│ "s-43" │ "open_ticket" │
└────────┴───────────────┘
1 rows
Evidence: Adversarial cases on the fixed server (mixed/computed heads, recursion, negation, arity)

>>> .kg create adv291
    Knowledge graph 'adv291' created.
    Switched to knowledge graph: adv291

>>> .kg use adv291
    Switched to knowledge graph: adv291

>>> +ticket3[("s-1", "refund", 120), ("s-2", "refund", 40), ("s-3", "bug", 10)]
    Inserted 3 fact(s) into 'ticket3'.

>>> +route(S, "finance", A) <- ticket3(S, "refund", A), A > 50
    Rule 'route' registered.

>>> +route(S, "support", A) <- ticket3(S, _, A)
    Rule 'route' registered.

>>> +escalated(S, Q) <- route(S, Q, _), Q = "finance"
    Rule 'escalated' registered.

>>> ?escalated(S, Q)
    s-1 | finance

>>> .why_not escalated("s-1", "finance")
    escalated("s-1", "finance") was derived:
      [rule] escalated("s-1", "finance")
        rule: escalated(S, Q) <- route(S, Q, _), Q = "finance"
        bindings: S="s-1", Q="finance"
      [rule] route("s-1", "finance", 120)
        rule: route(S, "finance", A) <- ticket3(S, "refund", A), A > 50
        bindings: S="s-1", A=120
      [base] ticket3("s-1", "refund", 120)

>>> .why_not escalated("s-2", "finance")
    escalated("s-2", "finance") was NOT derived:
    
      Rule: escalated (clause 0)
        escalated(S, Q) <- route(S, Q, _), Q = "finance"
        Blocker: Body predicate 0 (route("s-2", "finance", _)) failed: No matching tuples in route

>>> .why_not route("s-2", "finance", 40)
    route("s-2", "finance", 40) was NOT derived:
    
      Rule: route (clause 0)
        route(S, "finance", A) <- ticket3(S, "refund", A), A > 50
        Blocker: Comparison 40 > 50 failed: 40 vs 50
    
      Rule: route (clause 1)
        route(S, "support", A) <- ticket3(S, _, A)
        Blocker: Head unification failed: column 1: rule head has "support", target has "finance"

>>> .why_not route("s-9", "support", 1)
    route("s-9", "support", 1) was NOT derived:
    
      Rule: route (clause 0)
        route(S, "finance", A) <- ticket3(S, "refund", A), A > 50
        Blocker: Head unification failed: column 1: rule head has "finance", target has "support"
    
      Rule: route (clause 1)
        route(S, "support", A) <- ticket3(S, _, A)
        Blocker: Body predicate 0 (ticket3("s-9", _, 1)) failed: No matching tuples in ticket3

>>> +item[(1, 3), (2, 8)]
    Inserted 2 fact(s) into 'item'.

>>> +next(X, N + 1) <- item(X, N)
    Rule 'next' registered.

>>> ?next(1, V)
    1 | 4

>>> .why_not next(1, 4)
    next(1, 4) was derived:
      [rule] next(1, 4)
        rule: next(X, N+1) <- item(X, N)
        bindings: N=3, X=1
      [base] item(1, 3)

>>> .why_not next(1, 5)
    next(1, 5) was NOT derived:
    
      Rule: next (clause 0)
        next(X, N+1) <- item(X, N)
        Blocker: Head unification failed: column 1: N+1 is 4, target has 5

>>> +edge[(1, 2), (2, 3), (3, 4), (5, 6)]
    Inserted 4 fact(s) into 'edge'.

>>> +path(X, Y) <- edge(X, Y)
    Rule 'path' registered.

>>> +path(X, Z) <- path(X, Y), edge(Y, Z)
    Rule 'path' registered.

>>> +cut(X, "far") <- path(X, 4), !edge(X, 4)
    Rule 'cut' registered.

>>> ?cut(X, L)
    1 | far
    2 | far

>>> .why_not path(1, 4)
    path(1, 4) was derived:
      [rule] path(1, 4)
        rule: path(X, Z) <- path(X, Y), edge(Y, Z)
        bindings: Y=3, Z=4, X=1
      [rule] path(1, 3)
        rule: path(X, Z) <- path(X, Y), edge(Y, Z)
        bindings: X=1, Z=3, Y=2
      [rule] path(1, 2)
        rule: path(X, Y) <- edge(X, Y)
        bindings: Y=2, X=1
      [base] edge(1, 2)
      [base] edge(2, 3)
      [base] edge(3, 4)

>>> .why_not path(5, 4)
    path(5, 4) was NOT derived:
    
      Rule: path (clause 0)
        path(X, Y) <- edge(X, Y)
        Blocker: Body predicate 0 (edge(5, 4)) failed: No matching tuples in edge
    
      Rule: path (clause 1)
        path(X, Z) <- path(X, Y), edge(Y, Z)
        Blocker: Body predicate 1 (edge(6, 4)) failed: No matching tuples in edge

>>> .why_not cut(1, "far")
    cut(1, "far") was derived:
      [rule] cut(1, "far")
        rule: cut(X, "far") <- path(X, 4), !edge(X, 4)
        bindings: X=1
      [rule] path(1, 4)
        rule: path(X, Z) <- path(X, Y), edge(Y, Z)
        bindings: Z=4, X=1, Y=3
      [rule] path(1, 3)
        rule: path(X, Z) <- path(X, Y), edge(Y, Z)
        bindings: X=1, Z=3, Y=2
      [rule] path(1, 2)
        rule: path(X, Y) <- edge(X, Y)
        bindings: Y=2, X=1
      [base] edge(1, 2)
      [base] edge(2, 3)
      [base] edge(3, 4)
      [negation] edge(1, 4)

>>> .why_not cut(3, "far")
    cut(3, "far") was NOT derived:
    
      Rule: cut (clause 0)
        cut(X, "far") <- path(X, 4), !edge(X, 4)
        Blocker: Negated edge(3, 4) exists

>>> .why_not cut(1, "near")
    cut(1, "near") was NOT derived:
    
      Rule: cut (clause 0)
        cut(X, "far") <- path(X, 4), !edge(X, 4)
        Blocker: Head unification failed: column 1: rule head has "far", target has "near"

>>> .why_not cut(1)
    cut(1) was NOT derived:
    
      Rule: cut (clause 0)
        cut(X, "far") <- path(X, 4), !edge(X, 4)
        Blocker: Head unification failed: target has 1 values, rule head has 2
Evidence: Query vs .why_not sweep, fixed build (24/24 agree)
OK  query_rows=yes why_not=derived  candidate("s-43", "issue_refund")
OK  query_rows=yes why_not=derived  candidate("s-43", "open_ticket")
OK  query_rows=yes why_not=derived  candidate("s-43", "escalate")
OK  query_rows=no  why_not=NOT derived  candidate("s-43", "close")
OK  query_rows=no  why_not=NOT derived  candidate("s-44", "issue_refund")
OK  query_rows=yes why_not=derived  candidate("s-44", "open_ticket")
OK  query_rows=yes why_not=derived  candidate("s-44", "escalate")
OK  query_rows=no  why_not=NOT derived  candidate("s-44", "close")
OK  query_rows=no  why_not=NOT derived  candidate("s-99", "issue_refund")
OK  query_rows=no  why_not=NOT derived  candidate("s-99", "open_ticket")
OK  query_rows=no  why_not=NOT derived  candidate("s-99", "escalate")
OK  query_rows=no  why_not=NOT derived  candidate("s-99", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-43", "issue_refund")
OK  query_rows=yes why_not=derived  allowed("s-43", "open_ticket")
OK  query_rows=yes why_not=derived  allowed("s-43", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-43", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-44", "issue_refund")
OK  query_rows=yes why_not=derived  allowed("s-44", "open_ticket")
OK  query_rows=yes why_not=derived  allowed("s-44", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-44", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "issue_refund")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "open_ticket")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "close")
agree=24 disagree=0
Evidence: Query vs .why_not sweep, base build (8 agree, 16 disagree)
BAD query_rows=yes why_not=NOT derived  candidate("s-43", "issue_refund")
BAD query_rows=yes why_not=NOT derived  candidate("s-43", "open_ticket")
BAD query_rows=yes why_not=NOT derived  candidate("s-43", "escalate")
BAD query_rows=no  why_not=NOT derived  candidate("s-43", "close")
BAD query_rows=no  why_not=NOT derived  candidate("s-44", "issue_refund")
BAD query_rows=yes why_not=NOT derived  candidate("s-44", "open_ticket")
BAD query_rows=yes why_not=NOT derived  candidate("s-44", "escalate")
BAD query_rows=no  why_not=NOT derived  candidate("s-44", "close")
BAD query_rows=no  why_not=NOT derived  candidate("s-99", "issue_refund")
BAD query_rows=no  why_not=NOT derived  candidate("s-99", "open_ticket")
BAD query_rows=no  why_not=NOT derived  candidate("s-99", "escalate")
BAD query_rows=no  why_not=NOT derived  candidate("s-99", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-43", "issue_refund")
BAD query_rows=yes why_not=NOT derived  allowed("s-43", "open_ticket")
BAD query_rows=yes why_not=NOT derived  allowed("s-43", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-43", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-44", "issue_refund")
BAD query_rows=yes why_not=NOT derived  allowed("s-44", "open_ticket")
BAD query_rows=yes why_not=NOT derived  allowed("s-44", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-44", "close")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "issue_refund")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "open_ticket")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "escalate")
OK  query_rows=no  why_not=NOT derived  allowed("s-99", "close")
agree=8 disagree=16
Evidence: .why proof tree JSON through the constant-head view
[
 {
  "nodes": {
   "n0": {
    "conclusion": {
     "args": [
      "s-43",
      "refund"
     ],
     "pred": "ticket"
    },
    "kind": "fact",
    "source": "edb"
   },
   "n1": {
    "bindings": {
     "S": "s-43"
    },
    "children": [
     "n0"
    ],
    "conclusion": {
     "args": [
      "s-43",
      "open_ticket"
     ],
     "pred": "candidate"
    },
    "kind": "rule",
    "rule_id": "candidate(S, \"open_ticket\") <- ticket(S, _)"
   },
   "n2": {
    "conclusion": {
     "args": [
      "s-43",
      "open_ticket"
     ],
     "pred": "blocked"
    },
    "kind": "negation",
    "negation": {
     "pattern": "\"s-43\", \"open_ticket\""
    }
   },
   "n3": {
    "bindings": {
     "S": "s-43",
     "T": "open_ticket"
    },
    "children": [
     "n1",
     "n2"
    ],
    "conclusion": {
     "args": [
      "s-43",
      "open_ticket"
     ],
     "pred": "allowed"
    },
    "kind": "rule",
    "rule_id": "allowed(S, T) <- candidate(S, T), !blocked(S, T)"
   }
  },
  "query": "__query__(_c0, _c1) <- allowed(_c0, _c1), _c0 = \"s-43\", _c1 = \"open_ticket\"",
  "revision": 18,
  "roots": [
   "n3"
  ],
  "version": 1
 }
]
Evidence: Before/after excerpt
BASE : .why_not allowed("s-43","open_ticket") -> was NOT derived: Body predicate 0 (candidate(...)) failed: No matching tuples in candidate
FIXED: .why_not allowed("s-43","open_ticket") -> was derived: [rule] allowed <- [rule] candidate(S,"open_ticket") <- [base] ticket("s-43","refund"), [negation] blocked(...)
BASE : .why_not allowed("s-43","issue_refund") -> Blocker: No matching tuples in candidate
FIXED: .why_not allowed("s-43","issue_refund") -> Blocker: Negated blocked("s-43", "issue_refund") exists
BASE : candidate clause 1 -> Head unification failed: Target arity 2 does not match head arity 2
FIXED: candidate clause 1 -> Head unification failed: column 1: rule head has "open_ticket", target has "issue_refund"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ src/provenance/why_not.rs:572 - When ClauseSearch stops at MAX_SEARCH_STEPS but has already recorded a failure, explain_clause shows that failure as the clause's blocker and gives no sign that the search was cut short. The reported premise does fail for the bindings shown, so nothing false is claimed. But the docs promise 'the attempt that got furthest', and after a truncated search a later branch could have got further. One way to make the explanation honest about this is to add a note (for example, appending 'search stopped after N body matches' to the clause text or bindings) when search.exhausted &amp;&amp; search.furthest.is_some().
  • ℹ️ src/protocol/handler.rs:907 - .why_not now evaluates the whole target relation with the result cap turned off (handler.rs:907) on every call, where it used to read only the base facts. This is needed for a correct derived / NOT-derived verdict and matches other uncapped evaluations in this file. The trade-off: one .why_not on a very large view now costs as much as a full uncapped ?view(...) query.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Issue repro: .why_not on allowed("s-43","open_ticket"), a row ?allowed returns, reports it as derived with a proof through the constant-head candidate view ✅ pass live issue291_after_full.txt; base: issue291_before_base.txt reports 'was NOT derived ... No matching tuples in candidate'
.why_not on the constant-head view itself, candidate("s-43","issue_refund"), reports it as derived instead of the 'Target arity 2 does not match head arity 2' error ✅ pass live issue291_after_full.txt; base shows the arity-2 error
Real refusal names the true blocker: .why_not allowed("s-43","issue_refund") blames the negated blocked fact, not a missing candidate tuple ✅ pass live issue291_after_full.txt: 'Blocker: Negated blocked("s-43", "issue_refund") exists'
Adversarial: rows not derived from a constant-head view (wrong constant, unknown session) name the differing head constant or the failing body atom, never a bogus arity message ✅ pass live issue291_after_full.txt (candidate s-44/s-99/close cases)
Adversarial: a target with the wrong number of values gets a plain count-mismatch message ✅ pass live adversarial_after.txt: 'target has 1 values, rule head has 2'
Adversarial: mixed constant/variable heads with a filter, computed heads (N+1), recursive path, and a constant-head view over a recursive view give .why_not verdicts that match the query ✅ pass live adversarial_after.txt
Sweep: across all 24 candidate/allowed tuples, .why_not says derived exactly when ?query returns a row ✅ pass live sweep_after.txt agree=24 disagree=0 (base sweep_before_base.txt: agree=8 disagree=16)
.why on a derived row through the constant-head view returns a full proof tree (rule -> rule -> base fact + negation) ✅ pass live why_proof_after.json
  • cargo build --release --bin inputlayer-server --bin inputlayer-client at target ae341701; launched the server on 127.0.0.1:18291 with a throwaway data dir, credentials file and config
  • Built base 6ac53223 (from git archive into a temp dir) and launched it on 127.0.0.1:18292 with its own throwaway data dir for before/after comparison
  • inputlayer-client --script issue291.iql (the issue #291 support demo) against the live fixed server
  • Node WebSocket driver drive.mjs (authenticate + execute frames) to capture the full explanation text for issue291.iql and adversarial.iql on both servers
  • Sweep over 24 tuples (candidate/allowed × 3 sessions × 4 actions) comparing ?rel(...) row presence to the .why_not rel(...) verdict on both servers
  • .why ?allowed(&#34;s-43&#34;, &#34;open_ticket&#34;) proof_trees JSON captured from the live fixed server
  • cargo test --release --lib why_not (26 targeted tests, including the new why_not_tests proptest; all pass)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 2 commits October 5, 2026 06:43
…rue blocker (#291)

.why_not read only the base facts, so a body atom over a derived view (such
as candidate(S, "issue_refund") <- ...) found no tuples and a derived row was
reported as NOT derived. It also took the first match of each body atom
without backtracking, so a dead-end first match refused a derivable row, and
it reported every head mismatch, including a differing constant, as
"Target arity 2 does not match head arity 2".

.why_not now evaluates the target relation (uncapped) and answers from its
derived relations. A row the base facts or the evaluation hold is reported
as derived, with its proof, as .why gives it; a refusal comes only when
?relation(values) has no row. For each clause, a backtracking search over
base facts and derived relations finds the attempt that gets furthest and
reports the premise it failed on, so every premise shown as holding holds.
Head mismatches name their cause: the value count, the column whose
constant differs, or the computed column (N+1, count<X>) that evaluates to
another value. A premise the explainer cannot evaluate (a function call, a
nearest-neighbour search) or a search past its limit yields a new
not_explained blocker instead of a made-up failure.

Body predicates are evaluated in one order for .why and .why_not: each as
soon as it can be, so comparisons wait for the atoms that bind them,
equalities assign variables (D = N * 2), and negations wait for every
predicate that can bind their variables. Negated atoms read the derived
relations too, so .why no longer proves a row through !blocked(...) when
blocked is derived and holds. .why also proves rows of rules with computed
head columns.

Regression tests cover the issue's reproduction, constant and mixed heads,
dead-end matches, recursion, negation over derived relations, computed and
aggregate heads, and a property test: for random small programs, .why_not x
refuses exactly when ?x has no row, and every premise it shows holds.
…head

# Conflicts:
#	gui/lib/docs-bundle.ts
#	src/protocol/handler.rs
@jsam
jsam merged commit 6e4c9cd into main Oct 5, 2026
21 checks passed
@jsam
jsam deleted the fm/fix-why-not-const-head branch October 5, 2026 13:13
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