Skip to content

feat(protocol): report per-statement fact counts structurally in write replies - #255

Merged
jsam merged 1 commit into
mainfrom
fm/en-en4
Oct 4, 2026
Merged

jsam merged 1 commit into
mainfrom
fm/en-en4

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Intent

please continue developing the input layer engine so we make sure that the symbolic engine is ready to go live and completely production ready while we develop the GoTM uh, market.

(Context: the captain approved the neurosymbolic decision engine plan; its milestone 'Engine readiness' makes the symbolic engine production-ready for the hosted decision API. This task is item EN-4: structured per-statement counts (E-1) — why: the gateway reads whether a guard token landed from data, not message text)

EN-4 / E-1 in substance: a write program's reply reports each statement's outcome structurally in the result, as statements: [{index, kind, inserted, deleted}], instead of only in per-statement message text (Inserted N fact(s) into '<rel>'., Update: N deleted, M inserted., ...). Today those counts exist only in message text and are lost when a program ends with a query. With structured counts the gateway and SDKs stop parsing message text to know whether a guarded write's token landed (a guarded insert compiles to the update form, and its inserted count decides whether it applied).

What Changed

  • Write replies now include a statements list of {index, kind, inserted, deleted} for each committed fact statement, in program order. kind is insert, delete or update, and the counts are the tuples each statement actually changed. The list is still sent when the program ends with a query. It is left out when the program committed no fact statements, for example a read, a program that rolled back, or one that only changed schema or rules. A guarded update that matched nothing appears with zero counts, so its inserted value shows whether the write landed.
  • The counts come from commit_write_run, which now returns them alongside the existing message rows. They travel on QueryResult and the ws-protocol ResultFrame, including streamed results, and StatementCounts and StatementKind are exported from both crates.
  • The WebSocket API guide and the AsyncAPI spec document the new statements field and the StatementCounts schema. New tests in tests/statement_counts_tests.rs, the ws-protocol server tests and the stream tests cover the field.

Risk Assessment

✅ Low: The change adds an optional field that is skipped when empty, so existing clients are unaffected; it is filled only from the single commit path, and every return after the commit (messages-only, failed query, query success) passes the counts through, including the WS streaming header; behavior tests cover rollback, a trailing query and guarded updates.

Testing

I built the real inputlayer-server and ran it with a disposable data dir. A Node WebSocket client then drove six scenarios over /ws: mixed insert/delete/update/rule programs, a write ending in a query, a guarded claim that lands and then is refused, a pure query, a rules-only program, a failing program that rolls back, and a write ending in a query larger than 1 MiB, which streams. Every reply's statements matched the documented contract, including on the streamed result_start header. I also ran the change's own statement_counts integration tests and the ws-protocol serde tests, and both passed. All temporary server, data and build state was removed afterwards.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Mixed write program (bulk insert, duplicate insert, rule, literal/absent/conditional delete, update) returns effective per-statement counts with the rule left out ✅ pass live ws-transcript.txt S1: statements [0 insert 3/0, 1 insert 0/0, 3 delete 0/1, 4 delete 0/0, 5 delete 0/1, 6 update 1/1]; index 2 (the rule) is absent
Write program ending with a query keeps the counts alongside the query rows ✅ pass live ws-transcript.txt S2: columns [T], rows [[t1]], statements [0 insert 1/0, 1 insert 1/0]
Guarded claim (update form) reports inserted=1 when the token lands and inserted=0 when the guard refuses it, also when a query follows ✅ pass live ws-transcript.txt S3 attempts 1, 2 and claim+query
Adversarial: a pure query or a rules-only program carries no statements field ✅ pass live ws-transcript.txt S4: both result frames have no statements key
Adversarial: a failing program that rolls back lists no counts and applies nothing ✅ pass live ws-transcript.txt S5: errors[index 1, validation], no statements; follow-up ?item(100) returns 0 rows
A write ending in a query over 1 MiB streams, and the counts ride on the result_start header ✅ pass live ws-transcript.txt S6: result_start statements [0 insert 1/0, 1 delete 0/1], 750 rows in 2 chunks, then result_end
Evidence: Live WebSocket transcript of all scenarios
auth: authenticated

\### S1 mixed write program
> +item[(1,), (2,), (3,)]
> +item(1)
> +big(X) <- item(X), X > 5
> -item(2)
> -item(9)
> -item(X) <- item(X), X > 2
> -item(1), +item(10) <- item(1)
result: {"type":"result","id":"S1 mixed write program","columns":["message"],"rows":[["Inserted 3 fact(s) into 'item'."],["Inserted 0 fact(s) into 'item'."],["Rule 'big' registered."],"... 7 rows total"],"row_count":7,"total_count":7,"truncated":false,"row_provenance":["unknown","unknown","unknown","unknown","unknown","unknown","unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":3,"deleted":0},{"index":1,"kind":"insert","inserted":0,"deleted":0},{"index":3,"kind":"delete","inserted":0,"deleted":1},{"index":4,"kind":"delete","inserted":0,"deleted":0},{"index":5,"kind":"delete","inserted":0,"deleted":1},{"index":6,"kind":"update","inserted":1,"deleted":1}]}

\### S2 write program ending with query
> +task("t1")
> +held("none")
> ?task(T)
result: {"type":"result","id":"S2 write program ending with query","columns":["T"],"rows":[["t1"]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0},{"index":1,"kind":"insert","inserted":1,"deleted":0}]}

\### S3 guarded claim attempt 1 (should land)
> -il_ghost(0), +held(T) <- task(T), T = "t1", !held(T)
result: {"type":"result","id":"S3 guarded claim attempt 1 (should land)","columns":["message"],"rows":[["Update: 0 deleted, 1 inserted."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"update","inserted":1,"deleted":0}]}

\### S3 guarded claim attempt 2 (must not land)
> -il_ghost(0), +held(T) <- task(T), T = "t1", !held(T)
result: {"type":"result","id":"S3 guarded claim attempt 2 (must not land)","columns":["message"],"rows":[["Update: 0 deleted, 0 inserted."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"update","inserted":0,"deleted":0}]}

\### S3 guarded claim + query in same program
> -il_ghost(0), +held(T) <- task(T), T = "t1", !held(T)
> ?held(X)
result: {"type":"result","id":"S3 guarded claim + query in same program","columns":["X"],"rows":[["none"],["t1"]],"row_count":2,"total_count":2,"truncated":false,"row_provenance":["unknown","unknown"],"errors":[],"statements":[{"index":0,"kind":"update","inserted":0,"deleted":0}]}

\### S4 pure query (no statements field)
> ?item(X)
result: {"type":"result","id":"S4 pure query (no statements field)","columns":["X"],"rows":[[10]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[]}

\### S4 schema/rule only (no statements field)
> +derived(X) <- item(X)
result: {"type":"result","id":"S4 schema/rule only (no statements field)","columns":["message"],"rows":[["Rule 'derived' registered."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[]}

\### S5 failing program rolls back (no statements)
> +item(100)
> +item(1, 2, 3)
result: {"type":"result","id":"S5 failing program rolls back (no statements)","columns":["message"],"rows":[["Arity mismatch for relation 'item': existing arity is 1, but trying to insert tuples with arity 3 (rolled back: none of the program's 2 write statements 0-1 was applied)"]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[{"index":1,"code":"validation","message":"Arity mismatch for relation 'item': existing arity is 1, but trying to insert tuples with arity 3 (rolled back: none of the program's 2 write statements 0-1 was applied)"}]}

\### S5 verify rollback
> ?item(100)
result: {"type":"result","id":"S5 verify rollback","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"errors":[]}

\### S6 seed batch 0
> +blob[(0, "x…(2000 chars)
result: {"type":"result","id":"S6 seed batch 0","columns":["message"],"rows":[["Inserted 250 fact(s) into 'blob'."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":250,"deleted":0}]}

\### S6 seed batch 1
> +blob[(250, "x…(2000 chars)
result: {"type":"result","id":"S6 seed batch 1","columns":["message"],"rows":[["Inserted 250 fact(s) into 'blob'."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":250,"deleted":0}]}

\### S6 seed batch 2
> +blob[(500, "x…(2000 chars)
result: {"type":"result","id":"S6 seed batch 2","columns":["message"],"rows":[["Inserted 250 fact(s) into 'blob'."]],"row_count":1,"total_count":1,"truncated":false,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":250,"deleted":0}]}

\### S6 small write ending in >1MiB query (streamed)
> +blob(9999, "y")
> -blob(0, "x…(2000 chars)
result_start: {"type":"result_start","id":"S6 small write ending in >1MiB query (streamed)","columns":["I","S"],"total_count":750,"truncated":false,"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0},{"index":1,"kind":"delete","inserted":0,"deleted":1}]}
(750 rows in chunks) result_end: {"type":"result_end","id":"S6 small write ending in >1MiB query (streamed)","row_count":750,"chunk_count":2}
Evidence: Guarded claim: token landed vs refused, read from data
attempt 1: rows [["Update: 0 deleted, 1 inserted."]] statements [{"index":0,"kind":"update","inserted":1,"deleted":0}]
attempt 2: rows [["Update: 0 deleted, 0 inserted."]] statements [{"index":0,"kind":"update","inserted":0,"deleted":0}]
claim + ?held(X): columns ["X"] rows [["none"],["t1"]] statements [{"index":0,"kind":"update","inserted":0,"deleted":0}]

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ packages/inputlayer-py/src/inputlayer/program.py:469 - The SDKs still work out guarded-write outcomes by parsing message text: packages/inputlayer-py/src/inputlayer/program.py:469 uses _UPDATED regex and packages/inputlayer-js/src/program.ts:422 uses UPDATED regex. The engine now returns structured statements counts, but neither SDK reads them yet. Moving the SDKs over is the follow-up the intent describes as a consequence of E-1, not something EN-4 itself requires. Nothing is broken today, because the message rows are unchanged.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Mixed write program (bulk insert, duplicate insert, rule, literal/absent/conditional delete, update) returns effective per-statement counts with the rule left out ✅ pass live ws-transcript.txt S1: statements [0 insert 3/0, 1 insert 0/0, 3 delete 0/1, 4 delete 0/0, 5 delete 0/1, 6 update 1/1]; index 2 (the rule) is absent
Write program ending with a query keeps the counts alongside the query rows ✅ pass live ws-transcript.txt S2: columns [T], rows [[t1]], statements [0 insert 1/0, 1 insert 1/0]
Guarded claim (update form) reports inserted=1 when the token lands and inserted=0 when the guard refuses it, also when a query follows ✅ pass live ws-transcript.txt S3 attempts 1, 2 and claim+query
Adversarial: a pure query or a rules-only program carries no statements field ✅ pass live ws-transcript.txt S4: both result frames have no statements key
Adversarial: a failing program that rolls back lists no counts and applies nothing ✅ pass live ws-transcript.txt S5: errors[index 1, validation], no statements; follow-up ?item(100) returns 0 rows
A write ending in a query over 1 MiB streams, and the counts ride on the result_start header ✅ pass live ws-transcript.txt S6: result_start statements [0 insert 1/0, 1 delete 0/1], 750 rows in 2 chunks, then result_end
  • CARGO_TARGET_DIR=/tmp/nm-en4-target cargo build --bin inputlayer-server then ran it on 127.0.0.1:18431 with a throwaway --data-dir and a bootstrap API key
  • Node 22 WebSocket driver (drive.mjs) authenticated with the API key and sent execute frames for scenarios S1–S6 against the live server; transcript saved as evidence
  • cargo test --test statement_counts_tests (6 passed)
  • cargo test in ws-protocol (includes server::tests::statement_counts_are_listed_only_when_a_fact_statement_committed; all passed)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…e replies

A result now lists every fact statement its program committed in
`statements: [{index, kind, inserted, deleted}]`, with effective counts,
so a client reads whether a write (such as a guarded update's token)
landed from data instead of parsing message text. The list rides on the
reply even when the program ends with a query, whose rows used to replace
the count messages.
@jsam

jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Heavy checks deferred to the release checkpoint on the dedicated benchmark machine: the perf gate (make perf-gate), session-scale and load benchmarks, and long oracle runs. Under the dev-box rule they don't run here; the local perf-gate attempt was INVALID because the baseline had too few samples, so it measured no regression. The change adds one small list per committed write program, takes no locks and does nothing on the read path.

@jsam
jsam merged commit 6832903 into main Oct 4, 2026
21 checks passed
@jsam
jsam deleted the fm/en-en4 branch October 4, 2026 19:05
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