diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f6834e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+.kb/
+
+# build output
+bin/
+obj/
+artifacts/
+*.user
+.vs/
+TestResults/
+
+# runtime state + local models
+data/
+models/
+demo-data/
\ No newline at end of file
diff --git a/Automind.slnx b/Automind.slnx
new file mode 100644
index 0000000..be84f1d
--- /dev/null
+++ b/Automind.slnx
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..74e464f
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ 14.0
+ enable
+ enable
+ true
+ true
+ false
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..05d4342
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,56 @@
+
+
+
+ true
+ false
+
+ 1.0.0-beta.24
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..765346e
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4bab5f9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,188 @@
+# Automind / Universalis on Reaqtor
+
+A .NET 10 implementation of Erik Meijer's **neural computer** — the architecture from *Virtual Machinations: Using Large Language Models as Neural Computers* (ACM Queue, 2024) and *Unleashing the Power of End-User Programmable AI* (ACM Queue, 2025) — with **Nuqleon / Bonsai / Reaqtive / Reaqtor as the load-bearing substrate**, integrated with **Microsoft.Extensions.AI** against a **local Ollama** model (granite3.3:8b).
+
+The defining property: **a reasoning derivation is a durable standing query**. Kill the process mid-thought — mid-LLM-call, mid-tool-call, even mid-rule — restart it, and the derivation resumes from the last checkpoint and completes, re-issuing exactly the in-flight work that was lost.
+
+## The papers' concepts → this implementation
+
+| Neural computer (papers) | Realization |
+|-------------------------------------------------|---------------------------------------------------------------------------------|
+| Reasoning engine (control unit) | Pure `DerivationStep` state machine hosted in a checkpointed Reaqtor operator |
+| LLM as branch predictor | `IChatClient` (OllamaSharp) with streaming hedge-cut + assistant-prefill resume |
+| Environment σ (register set) | `var → JSON` map in checkpointed operator state; the model NEVER sees values |
+| Tools as instruction set (relations) | URI-identified artifacts (`automind://tools/*`) invoked by the engine |
+| Intentional representation | Structured IR (JSON) + **Bonsai expression trees** over `universalis://` params |
+| Query comprehensions (paper: Kotlin DataFrames) | **LINQ** pipelines over JSON rows — nested results, HAVING and all |
+| RAG as virtual memory | In-process ONNX embeddings (bge-micro-v2) paging rules/docs into the context |
+| Tree-of-thought backtracking | Choice points + value-sanitized hints + temperature ladder |
+| Pre/post-condition contracts | Vanilla Universalis clauses; pre gates the run, post restarts the derivation |
+| Self-learning | Successful derivations become durable rules (IR + Bonsai) that run LLM-free |
+
+## Quickstart
+
+Prereqs: .NET 10 SDK and Ollama running with `granite3.3:8b`. Everything else — including the
+Reaqtor/Reaqtive/Nuqleon stack (`1.0.0-beta.24`) — restores from nuget.org.
+
+```pwsh
+dotnet test Automind.slnx --filter "TestCategory!=RequiresOllama" # 176 tests, no model needed
+dotnet test Automind.slnx # + live Ollama & embedding tests
+
+# optional: enable RAG (fetches bge-micro-v2, ~66 MB, one-time)
+pwsh scripts/fetch-embedding-model.ps1
+
+dotnet run --project src/Automind.Cli -- ask "What is the current weather in Palo Alto?" --data .\data
+dotnet run --project src/Automind.Cli -- repl --data .\data
+```
+
+## The flagship demo — durable reasoning
+
+```pwsh
+# Act 1: the process kills itself the instant the first tool call is durably checkpointed
+$env:AUTOMIND_CHAOS = "tool"
+dotnet run --project src/Automind.Cli -- ask "What is the current weather in Palo Alto?" --data .\demo
+# ☠ AUTOMIND_CHAOS: killing the process now (state is checkpointed)
+
+# Act 2: restart; the engine recovers the standing query, re-issues the in-flight call, finishes
+$env:AUTOMIND_CHAOS = $null
+dotnet run --project src/Automind.Cli -- resume --data .\demo
+# ⟲ resuming derivation … — replaying its trace
+# @weatherPaloAlto ← "Sunny and 80°F" (via WEATHER)
+# ⇝ Sunny and 80°F
+# ╭─ answer … ─╮ (exactly once)
+```
+
+`AUTOMIND_CHAOS=segment:N` kills mid-generation instead. Other commands: `rules` (learned-rule library), `learn-doc ` (ingest into virtual memory), `store` (dump the durable tables), `ask --learn ` (save the derivation as a reusable rule).
+
+## Mode B — whole-program synthesis
+
+`ask --mode-b` trades the hedge-by-hedge interception protocol for **one schema-constrained completion** returning the complete program in the papers' `{comment|expression}[]` interchange form (streamed and accumulated — grammar-constrained decoding is slow, and a non-streaming call times out). `PaperShape.Import` re-parses every expression through the same hedge grammar and the walk feeds (prose, hedge) pairs through the same literate recognizer, so conditionals, queries, tools, rules, teachings, and contracts behave **exactly as in Mode A** — including durable tool suspension mid-walk. Only the backtracking is coarse: any failure regenerates the whole program with the escalation-counted failure as feedback; the same failure three times fails fast, a generation cut at the token cap is taught truthfully (write a shorter program) while the cap grows across attempts, and the request budget binds on every path. Mode B is the conformance floor for models that cannot hold the interception protocol — measured, it is exactly what rescues gemma4 (see the model matrix below).
+
+## MCP — external tools as Universalis predicates
+
+`--mcp ""` (repeatable; `--mcp-tools` allowlists a subset) bridges an MCP stdio server into the tool registry: tool names uppercase to the catalog convention while **parameter names stay verbatim** (the engine keys call arguments by them); required schema properties become In-params plus one Out-param; array-typed parameters take whole lists (no zip-lifting); results pass through **raw when they are JSON** — so pattern destructuring works on them — and string-encode when prose. Idempotency comes only from the server's own `readOnlyHint`/`idempotentHint` annotations: recovery synthesizes a failure and backtracks instead of re-firing a non-idempotent call that was in flight at a kill. Tools whose schemas use `allOf`/`$ref` composition are skipped loudly at connect time, and the bridged server commands persist with the data directory, so a resume without `--mcp` reconnects them automatically.
+
+Invocation flows through the durable hedge protocol, **not** the chat client's native function calling — register discipline, checkpointed suspension, and backtracking apply to bridged tools unchanged. (Live-proven: the model *guessed* a bridged tool's result inside a guard, and the engine evaluated the guess false and crossed the branch out — the model never possesses the value.) A self-contained sample server lives at `tools/McpSampleServer`:
+
+```pwsh
+dotnet run --project src/Automind.Cli -- ask "Reverse the word 'reaqtor' and bind it as @reversed." `
+ --mcp "dotnet tools/McpSampleServer/bin/Debug/net10.0/McpSampleServer.dll" --data .\d-mcp
+```
+
+## Observability
+
+`--otel` exports traces/metrics to console/OTLP; `--otel-log ` appends spans and metric snapshots as JSON lines (`run-demos.ps1` passes it automatically — every demo leaves `\otel.jsonl`, and a retried demo preserves the failed attempt's log as `.attemptN.otel.jsonl`). The money metric for durability is `automind.llm.reissues` — nonzero after recovery is the substrate visibly re-driving in-flight reasoning. The reasoning-quality signals are `automind.backtracks` / `automind.restarts` / `automind.repairs` / `automind.derivation.failures`; `automind.llm.segment.duration` (min/max included in the file export) profiles generation speed, which swings 6–8× with the model server's mood.
+
+The kernel is pure and hosts no telemetry — the substrate observes around it: every kernel trace event rides the `derivation.step` spans as `automind.trace.*` events (segments, bindings, guards, backtracks, repairs), so the span stream reconstructs the complete internal logic flow. Alongside: `llm.complete` (model, temperature, seed, prefill/segment sizes, hedge cuts), `tool.invoke` (URI, route, status — the derivation path and the router artifact) with `mcp.call` children for bridged tools, `engine.create` / `engine.recover` (in-flight and conversation counts), `engine.checkpoint`, `store.open` (a backup fallback is an **Error**-status span, never silent), `store.persist` (bytes, edit count, `File.Replace` retries, the `automind.snapshot.bytes` histogram — and a commit that changed nothing skips the rewrite entirely, tagged `automind.store.skipped=true`), `rule.define`, and `memory.recall` / `memory.index` (hits, best score). Deliberately uninstrumented: the kernel and Universalis.Core (pure by design — observed via the bridged traces), per-output egress delivery, and the `AUTOMIND_CHAOS` kill switch (nothing can flush across a deliberate `FailFast`).
+
+The CLI surfaces generation wall time directly: a heartbeat line when a generation exceeds 20 s of silence, duration annotations on slow segments, and an `LLM: N segment(s), X s total, slowest Y s` footer on every answer — a healthy slow run must never read as a hang.
+
+## The samples from the papers
+
+Every worked example in the two papers is implemented — the interactive ones as runnable `demo` scenarios, the rest as golden tests, live tests, or few-shot exemplars. This section maps each paper sample to where it lives.
+
+### The runnable demo pack
+
+`demo list` enumerates the scenarios:
+
+```pwsh
+dotnet run --project src/Automind.Cli -- demo weather-rule --data .\d1
+
+# or run the whole pack: builds, preflights Ollama, gives each demo a fresh
+# state dir, and prints a pass/fail summary. -Retries 1 reruns a failed roll
+# once (8B derivations are stochastic).
+pwsh scripts/run-demos.ps1 -Retries 1
+```
+
+A demo whose *point* is a refusal (`contracts-violation`) declares `ExpectFailure` and exits 0 when the derivation is refused.
+
+| Scenario | Paper | What it proves live |
+|-----------------------|-------|---------------------------------------------------------------------------------------------------------|
+| `weather-rule` | 1 | The composed WEATHER rule (GEO_CODE → WEATHER_GOV → HTTP_GET, JSON patterns digging out each field) **shadows** the primitive tool; one invocation → three chained tool calls, zero LLM calls inside the rule |
+| `bulk-pdf` | 2 | Loopless bulk processing: TO_PDF takes ONE file, the model calls it once with the LIST — `⚙ TO_PDF(...) ×3`, zip lifting fans it out, real stub PDFs land on disk |
+| `contracts` | 2 | Pre-conditions gate the profit question; post-conditions check `@P == ((@S-@B)/@B)*100` after the fact |
+| `contracts-violation` | 2 | `@B = 0` violates `[@B > 0]` → instant, traced refusal — **zero** LLM calls spent on an invalid ask |
+| `btc-decision` | 2 | The conditional checklist: the engine evaluates every guard (`? … → false`), runs the taken branch, crosses out the other (`✗`), and the declared output `@btcLeft` rides into the answer |
+| `team-selection` | 2 | The flagship grouped query as an **intentional program**: a stored STRONG_GROUPS rule compiles group-by / mean / min / collect / HAVING to a LINQ pipeline over JSON rows — fully nested `members` in the result, model channels one hedge |
+
+### Complete sample coverage
+
+| Paper sample | Where it lives |
+|---|---|
+| **1** — WEATHER relation, `"Palo Alto" ⇓ "Sunny and 80°F"` (table 1) | The canned WEATHER primitive returns exactly that; used by every weather test and demo |
+| **1** — variable-passing transcript (`@weatherPaloAlto`, `⇝` display, "the city between Mountain View and Menlo Park") | Byte-for-byte in the `Weather_HappyPath` golden test, the live Ollama test, and the flagship chaos demo |
+| **1** — ReAct interception (cut before `]`, ignore the hallucinated value) | The interception protocol itself: streaming hedge-cut + engine-appended `]` + prefill resume |
+| **1** — the composed WEATHER rule (GEO_CODE → WEATHER_GOV → HTTP_GET with `{... "forecast": @url ...}` patterns) | `demo weather-rule` + rule-frame tests |
+| **1** — the naked-model denial ("I don't have access to realtime information…") | Reproduced verbatim on raw granite3.3 (no tools); see below |
+| **1** — the 10000°F model-interference example | Prevented architecturally by the register discipline; see below |
+| **1** — RAG as virtual memory (context-stuffed forecast Q&A) | `Automind.Memory` pager + `learn-doc`; doc-grounded questions answer from paged chunks |
+| **1** — `[@Z is @X+"hello"]` type error → no derivation → backtrack | Evaluator type failure feeds tree-of-thought backtracking (golden tests) |
+| **1** — big-step ReAct semantics, fixed-mode Prolog, tree of thought | These *are* `DerivationStep`, the evaluator's mode discipline, and choice-point backtracking |
+| **2** — apples profit, including the paper's own `(@D/@B)*100)` paren typo | Golden + live tests, `demo contracts`; the parser heals the exact typo (dedicated test) |
+| **2** — formulas⇄values "live programming" rendering | The renderer's two modes over σ (`[@D is (@S-@B)]` ⇄ `[7 is (17-10)]`) in the CLI trace |
+| **2** — pre-conditions `[@B>0]`, `[@S>=0]` and implication-form post-conditions | `demo contracts` (holds) and `demo contracts-violation` (instant engine refusal, zero LLM) |
+| **2** — BTC/MSFT conditional checklist (TODAY, STOCK `{..."close":...}`, SEARCH) | `demo btc-decision` — guards engine-evaluated, untaken branch crossed out |
+| **2** — toPdf/listFiles loopless bulk conversion | `demo bulk-pdf` — `⚙ TO_PDF(...) ×3` from a single hedge |
+| **2** — customers-in-Palo-Alto count query | Few-shot exemplar + passing live integration test |
+| **2** — World Cup players grouped query (group/mean/min/collect/HAVING, nested players) | `demo team-selection` + query-pipeline unit tests on the exact query |
+| **2** — STOCK("IBM") messy JSON blob + `{ ... "volume": @V ... }` pattern | Pattern-matcher tests bind @V/@P/@X from the paper's blob |
+| **2** — the intentional representation (`{comment}/{expression}` JSON array) | `PaperShape` import/export with round-trip tests (the durable form adds Bonsai) |
+| **2** — self-learning via Tennent abstraction | `ask --learn `, the `rules` command, Bonsai round-trip + kill-survival tests |
+
+### The two "anti-examples": denial and interference
+
+Paper 1 opens with the model **denying** it can answer ("I'm sorry, but I don't have access to realtime information…"). Raw granite3.3 reproduces this almost verbatim when asked `What is the current temperature in Palo Alto?` with no tools — and the same model, on the same question, calls `[WEATHER("Palo Alto", @weatherPaloAlto)]` inside Automind. The before/after is the whole thesis in one exchange. If a completion ever *were* denial-shaped (prose, no hedges), the essay guard backtracks it with "act instead of describing" steering — live-proven, since essays are the same failure class.
+
+Paper 1's **10000°F interference** example (vanilla ReAct lets the model "correct" a tool result it finds implausible) is prevented by construction rather than detection: tool results live in σ inside checkpointed operator state and are never serialized into any prompt — the model reads back only `[WEATHER("Palo Alto", @weatherPaloAlto)]` and the engine-appended `]`, and user-visible values are substituted engine-side at display hedges. To corrupt a value the model would have to generate it, and it never possesses it. (The golden test asserts the invariant: the prompt after binding must not contain the value.) Two deliberate boundaries: *question inputs* are disclosed to the model — they're the user's own values, and σ stays authoritative since a conflicting re-bind is an error — and the architecture guarantees displayed values, not the model's prose beside them.
+
+### Deliberate deviations from the papers
+
+- Query comprehensions compile to **LINQ**, not the paper's Kotlin DataFrames (this project's substrate is Meijer's own .NET lineage).
+- GEO_CODE / WEATHER_GOV / HTTP_GET / STOCK / SEARCH are **canned facts** shaped like the real APIs (NWS, TwelveData) so demos run deterministic and offline; LIST_FILES / TO_PDF do real file I/O with stub PDFs.
+- The paper's aside that users can ask for the **equivalent SQL** of a query has no renderer here.
+- The apples-worth-in-gold illustration is covered as the intentional-representation *shape* only — WOLFRAM / TO_DOUBLE are not registered tools.
+- `team-selection` has the model **invoke** the stored query rather than author it live: granite3.3:8b reliably channels programs but reimplements (SQL, Python, accumulators) when asked to author or even transcribe the five-bullet form — the paper's authoring transcript came from a frontier model.
+- The 10000°F scenario has no dedicated end-to-end regression test — the register discipline is asserted structurally in the golden tests instead.
+
+## How a derivation runs
+
+1. A question becomes a **Reaqtor subscription**: `automind://derivation(id, envelope)` → egress topic. The subscription — including the question — is write-ahead-logged.
+2. The model generates literate Universalis; the bridge streams it through a bracket-depth scanner and **cuts before the closing `]`** of each hedge (client-side abort — a `]` stop sequence would false-cut inside JSON array patterns).
+3. The kernel parses the hedge and executes it: bindings/`is`/patterns inline; tool calls emit effects and suspend; display hedges `[@x]` show values to the *user only* (⇝). The engine appends `]` and resumes generation — the model narrates over *names*, never values.
+4. Every step mutates checkpointed operator state (`σ`, program IR, choice points, rule frames, pending effects). The host checkpoints per step (100 ms debounce) + every 5 s.
+5. Recovery re-issues pending effects with the *same request ids*; the deterministic step function drops stale completions. At-least-once end to end, deduplicated by sequence ids.
+
+## Solution layout
+
+```
+src/Universalis.Core the language: IR, hedge scanner/parser, literate recognizer, evaluator (σ, is, one-way patterns w/ DFS key search, zip lifting), LINQ query pipeline, contracts, Bonsai compiler + serialization, renderer
+src/Automind.Kernel the control unit: pure Step phase machine, prompting (LAWS + few-shots), backtracking, rule frames, learning — no I/O, no clocks, deterministic
+src/Automind.Reaqtor the substrate: engine host (Shebang-derived), kill-safe file store, DerivationObservable driver, tool router artifact, reliable ingress/ egress, catalogs (conversations/rules/docs), Ollama bridge, telemetry
+src/Automind.Tools primitive tools ("facts"): WEATHER (canned/deterministic), TODAY, MATH + the papers' demo facts (GEO_CODE, WEATHER_GOV, HTTP_GET, STOCK, SEARCH, LIST_FILES, TO_PDF)
+src/Automind.Mcp MCP bridge: schema→signature mapper, AIFunction→ITool adapter, stdio client lifecycle
+src/Automind.Memory RAG virtual memory: in-process BERT-ONNX embeddings + cosine recall
+src/Automind.Cli Spectre.Console host: repl/ask/resume/rules/learn-doc/store/demo, chaos switch, JSONL telemetry sink
+tools/McpSampleServer self-contained stdio MCP server (reverse, word_count) for tests and demos
+scripts/ run-demos.ps1 (demo pack + per-demo telemetry), run-model-matrix.ps1, fetch-embedding-model.ps1
+docs/model-matrix.md measured per-model conformance verdicts
+tests/ 176 fast tests (golden transcripts, kill/recover matrix, store atomicity, in-proc MCP loopback, paper examples as fixtures) + live suites (Ollama / embedding-model gated)
+```
+
+## Live-verified against granite3.3:8b
+
+Arithmetic chains, tool loops, conditional checklists (guards traced, untaken branches crossed out), query comprehensions, zip-lifted batches, and stored-rule invocation all run live. Several engine behaviors exist *because* live transcripts demanded them: bracket-free steering (models parrot feedback), duplicate-call and redundant-re-bind no-ops (models re-narrate what already happened), `[@x is TOOL(...)]` syntax tolerance, named arguments, extra-out-arg trimming, sentence-start section detection, block-level backtracking that rewinds to before the block, essay/phantom-variable detection (a completion that *narrates* results without computing them backtracks), **identical-failure escalation** (a model looping on the same error burns extra attempts and is forced to a deeper rewind instead of riding the whole request budget), **rule-frame scope isolation** (a rule body sees only its own locals — a caller's `@lat` must never collide with the body's `@lat`, or deterministic re-execution fails every retry), the `$` money-sigil tolerance, targeted parse teachings for `for`-loop / accumulator / conjunction / bracketed-`If`-bullet / imperative-verb instincts, **query loop-walkthrough tolerance** (a model that states a query declaratively and then re-narrates it as a loop — fused `Retain … and increment …` bullets, `- Initially, [@total = 0]` counter inits, `- If …` restatements of registered filters — compiles to the filter+count it means), and the **always-true-filter guard** (a filter over a field the row pattern never destructured *binds* instead of testing and would count every row — it now fails with the destructuring teach instead of answering wrongly), and the **If-sentence guard** (a σ-mutating hedge inside an "If …" *sentence* — as opposed to a checklist bullet — would execute unconditionally, poisoning σ with the untaken arm's value; it now backtracks with the checklist teach).
+
+Calibration for an 8B: granite3.3 reliably *channels* programs and *invokes* stored rules but cannot reliably *author* the five-bullet grouped query form live (it reimplements instead of transcribing) — which is why `team-selection` demonstrates the papers' intentional-program mechanism rather than live query authoring, and why derivations are stochastic: an occasional roll exhausts its retry budget where an immediate rerun passes.
+
+`scripts/run-model-matrix.ps1` measures any installed model against the protocol: the conformance gate (prefill continuation + hedge cut), four Mode A derivations, and the Mode B floor — verdicts land in [docs/model-matrix.md](docs/model-matrix.md). Reasoning-tuned models (qwen3) are handled by a streaming `…` suppressor in front of the hedge scanner: chain-of-thought must never execute, only the answer does. Measured verdicts (2026-07-16): **qwen2.5-coder:7b fully conformant and fastest across every cell** (a coding-tuned 7B holds the formal protocol best), granite3.3:8b Mode A (the session workhorse), qwen3:8b Mode A but fragile (thinking eats segment budget and wall clock), and gemma4 **Mode B only** — protocol-conformant but too slow per segment for multi-request derivations, rescued by the single whole-program generation, which is exactly the fallback Mode B exists to be.
+
+Resilience policies that keep stochastic rolls honest *and* alive: **declared outputs gate completion** (finishing without computing one backtracks with placement-aware teaching), **post-answer-noise skipping** (once every declared output is bound, a failing trailing hedge is skipped — never unwinds a finished derivation), and **tree-of-thought restart** (exhausting the search — choice points *or* backtracking depth — with budget to spare restarts the derivation from the question, engine notes carrying the accumulated teachings forward). A max-effort adversarial review round hardened the loop further: recovery never re-fires non-idempotent tools, terminal egress signals always resolve the waiting CLI (a drained-answer recovery can no longer hang `ask`), the LLM request budget binds on every issue path — success loops included — and an LLM transport outage self-heals with capped backoff instead of wedging until a restart.
+
+Measured over repeated full-pack runs: four demos pass first-attempt in 6–20 seconds near-deterministically *at typical generation speeds*, `btc-decision` (live JSON-extraction arithmetic, the hardest micro-skill for an 8B) passes ~75% first-attempt and reliably with the script's `-Retries 1` — and wall clock scales with the model server's mood (the same pack measured 6–8× slower on the same day with identical trajectories; the telemetry and the CLI's LLM footer make the difference legible).
+
+## Known limitations (v1)
+
+- Tool and rule calls inside conditional *branches* backtrack with a teaching hint (call before the checklist); comprehensions and rule bodies support them fully.
+- Rules persist as durable catalog rows (IR + Bonsai) rather than engine-defined artifacts.
+- Answers can carry model prose verbatim, including its occasional echo debris.
+- MCP bridging is stdio-only and maps required parameters only (optional MCP parameters are dropped — fixed-mode arity).
diff --git a/docs/model-matrix.md b/docs/model-matrix.md
new file mode 100644
index 0000000..2980dcd
--- /dev/null
+++ b/docs/model-matrix.md
@@ -0,0 +1,40 @@
+# Model-swap matrix
+
+Measured 2026-07-16 22:44 against http://localhost:11434 on HVR-SLS.
+Each cell is one gated live test (pass/fail, seconds). Verdicts: **Mode A** = conformance gate
++ ≥3/4 Mode A derivations; **Mode A (fragile)** = the derivations pass but the gate's tight
+segment budget does not; **Mode B only** = whole-program synthesis works where the
+interception protocol does not.
+
+| model | conformance | apples A | ticket A | customers A | weather A | apples B | verdict |
+|---|---|---|---|---|---|---|---|
+| gemma4:latest | ✓ 40s | ✗ 244s | ✗ 244s | ✗ 244s | ✗ 244s | ✓ 150s | **Mode B only** |
+| granite3.3:8b | ✓ 37s | ✓ 71s | ✗ 244s † | ✗ 106s † | ✓ 10s | ✓ 10s | **Mode A** † |
+| qwen2.5-coder:7b | ✓ 11s | ✓ 12s | ✓ 13s | ✓ 19s | ✓ 8s | ✓ 9s | **Mode A** |
+| qwen3:8b | ✗ 13s ‡ | ✓ 34s | ✓ 62s | ✗ 243s | ✓ 117s | ✗ 244s | **Mode A (fragile)** ‡ |
+
+† granite's two ✗ cells were cold-swap artifacts: this run cycled four models through GPU
+memory and both cells hit their 240 s test timeout cold; **both pass warm on immediate rerun**
+(and granite passes the full live suite 5/5 routinely — see the session log). The script now
+warms each model outside the test timeouts before its battery. Verdict corrected accordingly.
+
+‡ qwen3's conformance failure is real and instructive: with thinking enabled, the gate's
+64-token echo budget is consumed entirely by `` content — the filtered reply is EMPTY.
+Real derivations pass (3/4) because 512-token segments leave room to think *and* emit, but the
+overhead is paid on every segment (34–117 s vs qwen2.5-coder's 8–19 s) and it timed out the
+customers query and the grammar-constrained Mode B generation. Usable, with caveats.
+
+## Findings
+
+- **qwen2.5-coder:7b is the surprise headline**: 6/6, fully conformant, and the fastest across
+ every cell — a coding-tuned 7B holds the formal hedge protocol better than anything else
+ measured, including the general 8Bs. Worth making it the default for protocol-heavy work.
+- **gemma4 is the Mode B story validated**: conformant at the byte level but too slow per
+ segment for multi-request Mode A derivations inside the 240 s test window — yet the single
+ 150 s whole-program generation passes. Exactly the fallback Mode B was built to be.
+- **Thinking models pay the protocol tax twice** (qwen3): thought consumes segment budget and
+ wall clock; the `` suppressor keeps chain-of-thought from ever executing, but cannot
+ refund the time.
+
+Rerun a single cell with:
+`$env:AUTOMIND_OLLAMA_MODEL=''; dotnet test tests/Automind.Integration.Tests --filter "FullyQualifiedName~"`.
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..bcdddea
--- /dev/null
+++ b/global.json
@@ -0,0 +1,9 @@
+{
+ "sdk": {
+ "version": "10.0.300",
+ "rollForward": "latestPatch"
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/scripts/fetch-embedding-model.ps1 b/scripts/fetch-embedding-model.ps1
new file mode 100644
index 0000000..f092c14
--- /dev/null
+++ b/scripts/fetch-embedding-model.ps1
@@ -0,0 +1,27 @@
+# Fetches the bge-micro-v2 embedding model (BERT ONNX) for fully LOCAL, in-process embeddings.
+# No Ollama, no server, no generative model. Files land in \models\bge-micro-v2.
+param(
+ [string]$Destination = (Join-Path $PSScriptRoot "..\models\bge-micro-v2")
+)
+
+$ErrorActionPreference = "Stop"
+New-Item -ItemType Directory -Force $Destination | Out-Null
+
+$files = @(
+ @{ Url = "https://huggingface.co/TaylorAI/bge-micro-v2/resolve/main/onnx/model.onnx"; Name = "model.onnx" },
+ @{ Url = "https://huggingface.co/TaylorAI/bge-micro-v2/resolve/main/vocab.txt"; Name = "vocab.txt" }
+)
+
+foreach ($file in $files) {
+ $target = Join-Path $Destination $file.Name
+ if (Test-Path $target) {
+ Write-Host "already present: $target"
+ continue
+ }
+
+ Write-Host "downloading $($file.Url) ..."
+ Invoke-WebRequest -Uri $file.Url -OutFile $target
+ Write-Host ("saved: {0} ({1} MB)" -f $target, [math]::Round((Get-Item $target).Length / 1MB, 1))
+}
+
+Write-Host "done - Automind probes '\..\models\bge-micro-v2' and AUTOMIND_EMBEDDINGS."
diff --git a/scripts/run-demos.ps1 b/scripts/run-demos.ps1
new file mode 100644
index 0000000..5ce3ecc
--- /dev/null
+++ b/scripts/run-demos.ps1
@@ -0,0 +1,171 @@
+#Requires -Version 7
+<#
+.SYNOPSIS
+ Runs the Automind paper-example demos end to end against local Ollama.
+
+.DESCRIPTION
+ Builds the CLI (unless -SkipBuild), verifies Ollama and the chat model are
+ reachable, then runs each demo scenario serially with a fresh durable-state
+ directory and prints a pass/fail summary.
+
+ Demos are stochastic: an 8B model occasionally exhausts its retry budget on
+ a bad trajectory where an immediate rerun passes. Use -Retries 1 to rerun a
+ failed demo once (on a fresh directory) before counting it as failed.
+
+.PARAMETER Name
+ Subset of demos to run (default: all six, in pack order).
+
+.PARAMETER DataRoot
+ Root under which each demo gets its own fresh --data directory.
+
+.PARAMETER Retries
+ Extra attempts per demo after a failure (default 0).
+
+.EXAMPLE
+ pwsh scripts/run-demos.ps1
+.EXAMPLE
+ pwsh scripts/run-demos.ps1 -Name bulk-pdf,team-selection -Retries 1
+#>
+[CmdletBinding()]
+param(
+ [string[]]$Name,
+ [string]$DataRoot = (Join-Path $PSScriptRoot '..' 'demo-data'),
+ [string]$Endpoint = 'http://localhost:11434',
+ [string]$Model = 'granite3.3:8b',
+ # Generation speed swings 6-8x with Ollama's mood (measured live: the same demo at 15 s and
+ # 10 min on the same day) — 900 s would kill a CONVERGING btc roll on a slow day.
+ [int]$TimeoutSeconds = 1200,
+ [int]$Retries = 0,
+ [switch]$SkipBuild
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$exe = Join-Path $repoRoot 'src' 'Automind.Cli' 'bin' 'Debug' 'net10.0' 'Automind.Cli.exe'
+
+$allDemos = @('weather-rule', 'bulk-pdf', 'contracts', 'contracts-violation', 'btc-decision', 'team-selection')
+
+$demos = if ($Name) {
+ # `pwsh -File` passes "a,b" as one literal string (only interactive sessions split it).
+ $requested = @($Name | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+
+ foreach ($n in $requested) {
+ if ($n -notin $allDemos) {
+ throw "Unknown demo '$n'. Available: $($allDemos -join ', ')"
+ }
+ }
+ $allDemos | Where-Object { $_ -in $requested } # keep pack order
+} else {
+ $allDemos
+}
+
+# ---------------------------------------------------------------- preflight
+
+if (-not $SkipBuild -or -not (Test-Path $exe)) {
+ Write-Host 'building Automind.slnx …' -ForegroundColor DarkGray
+ dotnet build (Join-Path $repoRoot 'Automind.slnx') --verbosity quiet --nologo
+ if ($LASTEXITCODE -ne 0) { throw 'build failed' }
+}
+
+try {
+ $tags = Invoke-RestMethod "$Endpoint/api/tags" -TimeoutSec 5
+} catch {
+ throw "Ollama is not reachable at $Endpoint — start it first ($($_.Exception.Message))"
+}
+
+if (-not ($tags.models.name -contains $Model)) {
+ throw "model '$Model' is not installed in Ollama (have: $($tags.models.name -join ', ')). Run: ollama pull $Model"
+}
+
+if (-not (Test-Path (Join-Path $repoRoot 'models' 'bge-micro-v2' 'model.onnx'))) {
+ Write-Host 'note: embedding model missing — the memory pager will be off (optional; scripts/fetch-embedding-model.ps1 fetches it)' -ForegroundColor DarkYellow
+}
+
+# ---------------------------------------------------------------- run loop
+
+$results = foreach ($demo in $demos) {
+ $attempts = 0
+ $exit = $null
+ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ while ($attempts -le $Retries) {
+ $attempts++
+
+ $dataDir = Join-Path $DataRoot $demo
+
+ if ($attempts -eq 1) {
+ # Stale evidence from a previous pack run.
+ Get-ChildItem $DataRoot -Filter "$demo.attempt*.otel.jsonl" -ErrorAction SilentlyContinue | Remove-Item -Force
+ }
+
+ if (Test-Path $dataDir) {
+ # A retry wipes the data directory — preserve the FAILED attempt's telemetry first;
+ # the failure log is the evidence the retry exists to route around.
+ $prevLog = Join-Path $dataDir 'otel.jsonl'
+ if ($attempts -gt 1 -and (Test-Path $prevLog)) {
+ Move-Item $prevLog (Join-Path $DataRoot ("{0}.attempt{1}.otel.jsonl" -f $demo, ($attempts - 1))) -Force
+ }
+
+ Remove-Item -Recurse -Force $dataDir
+ }
+
+ Write-Host ''
+ Write-Host ('═' * 78) -ForegroundColor Cyan
+ Write-Host (" demo {0} (attempt {1}/{2})" -f $demo, $attempts, ($Retries + 1)) -ForegroundColor Cyan
+ Write-Host ('═' * 78) -ForegroundColor Cyan
+
+ # Every run leaves a telemetry artifact: spans (with the automind.trace.* logic-flow
+ # events) + metric snapshots, as JSON lines next to the demo's durable state.
+ $otelLog = Join-Path $dataDir 'otel.jsonl'
+
+ $p = Start-Process $exe `
+ -ArgumentList 'demo', $demo, '--data', $dataDir, '--endpoint', $Endpoint, '--model', $Model, '--quiet', '--otel-log', $otelLog `
+ -NoNewWindow -PassThru
+
+ if (-not $p.WaitForExit($TimeoutSeconds * 1000)) {
+ $p.Kill($true)
+ $exit = -1
+ Write-Host " ⏱ timed out after $TimeoutSeconds s (killed; state is checkpointed in $dataDir)" -ForegroundColor Red
+ } else {
+ $exit = $p.ExitCode
+ }
+
+ if ($exit -eq 0) { break }
+
+ if ($attempts -le $Retries) {
+ Write-Host ' ↻ failed roll — retrying on a fresh directory (8B derivations are stochastic)' -ForegroundColor DarkYellow
+ }
+ }
+
+ $stopwatch.Stop()
+
+ [pscustomobject]@{
+ Demo = $demo
+ Result = if ($exit -eq 0) { 'pass' } elseif ($exit -eq -1) { 'timeout' } else { 'fail' }
+ Attempts = $attempts
+ Duration = '{0:mm\:ss}' -f $stopwatch.Elapsed
+ }
+}
+
+# ---------------------------------------------------------------- summary
+
+Write-Host ''
+Write-Host ('═' * 78) -ForegroundColor Cyan
+Write-Host ' summary' -ForegroundColor Cyan
+Write-Host ('═' * 78) -ForegroundColor Cyan
+
+$results | Format-Table -AutoSize | Out-String | Write-Host
+
+$failed = @($results | Where-Object Result -ne 'pass')
+
+if ($failed.Count -gt 0) {
+ Write-Host ("{0} of {1} demo(s) did not pass. A failed roll usually passes on rerun:" -f $failed.Count, $results.Count) -ForegroundColor Yellow
+ foreach ($f in $failed) {
+ Write-Host (" pwsh scripts/run-demos.ps1 -Name {0}" -f $f.Demo) -ForegroundColor Yellow
+ }
+ exit 1
+}
+
+Write-Host 'all demos passed.' -ForegroundColor Green
+exit 0
diff --git a/scripts/run-model-matrix.ps1 b/scripts/run-model-matrix.ps1
new file mode 100644
index 0000000..5081be9
--- /dev/null
+++ b/scripts/run-model-matrix.ps1
@@ -0,0 +1,187 @@
+<#
+.SYNOPSIS
+ Runs the model-swap matrix: the live conformance gate plus the derivation battery
+ against every candidate Ollama model, and writes the verdict table.
+
+.DESCRIPTION
+ Per model, each live test runs as its own `dotnet test` invocation with
+ AUTOMIND_OLLAMA_MODEL set: the protocol conformance gate (prefill continuation +
+ hedge cut), four Mode A derivations (arithmetic, conditional checklist, query
+ comprehension, tool loop), and the Mode B whole-program synthesis floor.
+
+ Verdicts: 'Mode A' (conformance + >=3/4 Mode A derivations), 'Mode B only'
+ (whole-program synthesis passes where the interception protocol does not),
+ 'unusable'. First test per model pays the model-load cost — durations are
+ indicative, not benchmarks.
+
+.EXAMPLE
+ pwsh scripts/run-model-matrix.ps1
+ pwsh scripts/run-model-matrix.ps1 -Models granite3.3:8b,qwen3:8b
+#>
+param(
+ [string[]]$Models,
+ [string]$Endpoint = ($env:AUTOMIND_OLLAMA ?? 'http://localhost:11434'),
+ [string]$ReportPath = (Join-Path $PSScriptRoot '..' 'docs' 'model-matrix.md'),
+ [int]$TimeoutSeconds = 420,
+ [switch]$SkipBuild
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
+
+# The battery: display name -> FullyQualifiedName fragment. Order matters — conformance first.
+$battery = [ordered]@{
+ 'conformance' = 'Conformance_PrefillContinuation_And_HedgeCut'
+ 'apples A' = 'LiveDerivation_Apples_PureArithmetic'
+ 'ticket A' = 'LiveDerivation_Conditional_ChecklistDecision'
+ 'customers A' = 'LiveDerivation_Comprehension_CustomerCount'
+ 'weather A' = 'LiveDerivation_Weather_ToolCallLoop'
+ 'apples B' = 'LiveDerivation_ModeB_WholeProgramSynthesis'
+}
+
+# ---------------------------------------------------------------- preflight
+
+try {
+ $tags = Invoke-RestMethod -Uri "$Endpoint/api/tags" -TimeoutSec 5
+} catch {
+ throw "Ollama is not reachable at $Endpoint"
+}
+
+$installed = @($tags.models | ForEach-Object { $_.name })
+
+if (-not $Models) {
+ # Default: every installed chat model (embedding models can't chat).
+ $Models = $installed | Where-Object { $_ -notmatch 'embed' }
+}
+
+Write-Host "matrix candidates: $($Models -join ', ')" -ForegroundColor Cyan
+
+if (-not $SkipBuild) {
+ Write-Host 'building Automind.slnx …' -ForegroundColor DarkGray
+ dotnet build (Join-Path $repoRoot 'Automind.slnx') --verbosity quiet --nologo
+ if ($LASTEXITCODE -ne 0) { throw 'build failed' }
+}
+
+$testProject = Join-Path $repoRoot 'tests' 'Automind.Integration.Tests'
+$env:NO_COLOR = '1'
+$env:AUTOMIND_OLLAMA = $Endpoint
+
+# ---------------------------------------------------------------- run
+
+$results = foreach ($model in $Models) {
+ if ($model -notin $installed) {
+ Write-Host " ⤫ $model is not installed — skipped" -ForegroundColor DarkYellow
+ [pscustomobject]@{ Model = $model; Cells = $null; Verdict = 'not installed'; Seconds = 0 }
+ continue
+ }
+
+ Write-Host ''
+ Write-Host ('═' * 70) -ForegroundColor Cyan
+ Write-Host " model $model" -ForegroundColor Cyan
+ Write-Host ('═' * 70) -ForegroundColor Cyan
+
+ $env:AUTOMIND_OLLAMA_MODEL = $model
+ $cells = [ordered]@{}
+ $modelWatch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ # Warm the model OUTSIDE the test timeouts: cycling four models through GPU memory made
+ # cold cells time out and read as protocol failures (observed: granite3.3 'failed' two
+ # cells cold that it passes warm every time).
+ try {
+ $null = Invoke-RestMethod -Method Post -Uri "$Endpoint/api/generate" -TimeoutSec 300 -ContentType 'application/json' `
+ -Body (@{ model = $model; prompt = 'hi'; stream = $false } | ConvertTo-Json)
+ } catch {
+ Write-Host " (warm-up failed: $($_.Exception.Message))" -ForegroundColor DarkYellow
+ }
+
+ foreach ($name in $battery.Keys) {
+ $fqn = $battery[$name]
+ $stdout = New-TemporaryFile
+ $watch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ $p = Start-Process dotnet `
+ -ArgumentList 'test', $testProject, '--no-build', '--filter', "FullyQualifiedName~$fqn" `
+ -RedirectStandardOutput $stdout.FullName -NoNewWindow -PassThru
+
+ $finished = $p.WaitForExit($TimeoutSeconds * 1000)
+ if (-not $finished) { $p.Kill($true) }
+ $watch.Stop()
+
+ $clean = (Get-Content $stdout.FullName -Raw -ErrorAction SilentlyContinue) -replace "`e\[[0-9;]*m", ''
+ Remove-Item $stdout -Force -ErrorAction SilentlyContinue
+
+ $pass = $finished -and $clean -match 'succeeded:\s*1' -and $clean -match 'failed:\s*0'
+ $skipped = $clean -match 'skipped:\s*[1-9]' # Ollama unreachable → inconclusive
+
+ $cells[$name] = [pscustomobject]@{
+ Pass = $pass
+ Skipped = $skipped
+ Seconds = [math]::Round($watch.Elapsed.TotalSeconds)
+ }
+
+ $glyph = if ($pass) { '✓' } elseif ($skipped) { '~' } else { '✗' }
+ $color = if ($pass) { 'Green' } elseif ($skipped) { 'DarkYellow' } else { 'Red' }
+ Write-Host (" {0} {1,-12} {2,4}s" -f $glyph, $name, $cells[$name].Seconds) -ForegroundColor $color
+ }
+
+ $modelWatch.Stop()
+
+ $modeAPasses = @('apples A', 'ticket A', 'customers A', 'weather A' | ForEach-Object { $cells[$_] } | Where-Object Pass).Count
+ $verdict =
+ if ($cells['conformance'].Pass -and $modeAPasses -ge 3) { 'Mode A' }
+ elseif ($modeAPasses -ge 3) { 'Mode A (fragile)' } # derivations work; the gate's tight segment budget does not (thinking models eat it)
+ elseif ($cells['apples B'].Pass) { 'Mode B only' }
+ else { 'unusable' }
+
+ Write-Host " verdict: $verdict" -ForegroundColor Magenta
+
+ [pscustomobject]@{
+ Model = $model
+ Cells = $cells
+ Verdict = $verdict
+ Seconds = [math]::Round($modelWatch.Elapsed.TotalSeconds)
+ }
+}
+
+Remove-Item Env:\AUTOMIND_OLLAMA_MODEL -ErrorAction SilentlyContinue
+
+# ---------------------------------------------------------------- report
+
+$lines = [System.Collections.Generic.List[string]]::new()
+$lines.Add('# Model-swap matrix')
+$lines.Add('')
+$lines.Add("Measured $(Get-Date -Format 'yyyy-MM-dd HH:mm') against $Endpoint on $env:COMPUTERNAME.")
+$lines.Add('Each cell is one gated live test (pass/fail, seconds); the first test per model pays the')
+$lines.Add('model-load cost. Verdicts: **Mode A** = conformance gate + >=3/4 Mode A derivations;')
+$lines.Add('**Mode B only** = whole-program synthesis works where the interception protocol does not.')
+$lines.Add('')
+$lines.Add('| model | ' + (($battery.Keys | ForEach-Object { $_ }) -join ' | ') + ' | verdict |')
+$lines.Add('|---|' + (('---|' * $battery.Count)) + '---|')
+
+foreach ($r in $results) {
+ if ($null -eq $r.Cells) {
+ $lines.Add("| $($r.Model) | " + (('— | ' * $battery.Count)) + "$($r.Verdict) |")
+ continue
+ }
+
+ $cellText = ($battery.Keys | ForEach-Object {
+ $c = $r.Cells[$_]
+ if ($c.Pass) { "✓ $($c.Seconds)s" } elseif ($c.Skipped) { '~' } else { "✗ $($c.Seconds)s" }
+ }) -join ' | '
+
+ $lines.Add("| $($r.Model) | $cellText | **$($r.Verdict)** |")
+}
+
+$lines.Add('')
+$lines.Add('A ✗ on a stochastic derivation is one roll, not a verdict — the verdict thresholds absorb that;')
+$lines.Add('rerun a single cell with: `AUTOMIND_OLLAMA_MODEL= dotnet test tests/Automind.Integration.Tests --filter "FullyQualifiedName~"`.')
+
+New-Item -ItemType Directory -Force (Split-Path $ReportPath) | Out-Null
+Set-Content -Path $ReportPath -Value ($lines -join "`n") -Encoding utf8
+
+Write-Host ''
+Write-Host "report written to $ReportPath" -ForegroundColor Cyan
+
+$results | Format-Table Model, Verdict, Seconds -AutoSize
+
+exit ($results | Where-Object { $_.Verdict -eq 'unusable' } | Measure-Object).Count
diff --git a/src/Automind.Cli/Automind.Cli.csproj b/src/Automind.Cli/Automind.Cli.csproj
new file mode 100644
index 0000000..8f0940b
--- /dev/null
+++ b/src/Automind.Cli/Automind.Cli.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Automind.Cli/AutomindHost.cs b/src/Automind.Cli/AutomindHost.cs
new file mode 100644
index 0000000..3b3a014
--- /dev/null
+++ b/src/Automind.Cli/AutomindHost.cs
@@ -0,0 +1,539 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+
+using Automind.Kernel;
+using Automind.Kernel.Contract;
+using Automind.Reaqtor.Catalog;
+using Automind.Reaqtor.Client;
+using Automind.Reaqtor.Engine;
+using Automind.Reaqtor.IO;
+using Automind.Reaqtor.Llm;
+using Automind.Reaqtor.Reactive;
+using Automind.Reaqtor.Store;
+using Automind.Tools;
+
+using OllamaSharp;
+
+using Reaqtive.Scheduler;
+
+using Reaqtor.Shebang.Service;
+
+using Spectre.Console;
+
+namespace Automind.Cli;
+
+public sealed record HostOptions(string DataDirectory, string OllamaEndpoint, string Model, bool Verbose)
+{
+ /// Extra tools to register (demo scenarios add the papers' primitive facts here).
+ public Action? ConfigureTools { get; init; }
+
+ /// Pre-stored rules (session-scoped, not persisted) — e.g. the papers' composed WEATHER rule.
+ public IReadOnlyList? SeedRules { get; init; }
+
+ /// MCP stdio servers to bridge — one command line each ("dotnet path/Server.dll").
+ public IReadOnlyList? McpServers { get; init; }
+
+ /// Optional comma-separated allowlist of MCP tool names (MCP or predicate spelling).
+ public string? McpToolFilter { get; init; }
+}
+
+public sealed record DerivationResult(string DerivationId, bool Succeeded, string Payload);
+
+///
+/// The Automind process host: opens the durable store, stands up (or recovers) the engine,
+/// re-attaches in-flight conversations, runs the checkpoint policy, and — when
+/// AUTOMIND_CHAOS is set — kills the process deterministically right after a durable
+/// checkpoint at the requested point, for the durable-reasoning demo.
+///
+public sealed class AutomindHost : IAsyncDisposable
+{
+ private readonly PhysicalScheduler _scheduler;
+ private readonly FileQueryEngineStateStore _store;
+ private readonly AutomindIngressEgressManager _iemgr = new();
+ private readonly TraceRenderer _renderer;
+ private readonly ConcurrentDictionary> _completions = new();
+ private readonly ConcurrentDictionary _terminalSeen = new();
+ private readonly string? _chaos;
+ private int _chaosSegments;
+
+ private SimplerCheckpointingQueryEngine _engine = null!;
+ private CheckpointCoordinator _checkpoints = null!;
+ private Automind.Mcp.McpToolBridge? _mcpBridge;
+
+ public ConversationCatalog Conversations { get; }
+
+ public RuleCatalogStore Rules { get; }
+
+ public DocCatalogStore Docs { get; }
+
+ public Automind.Memory.IMemoryPager? Memory { get; private set; }
+
+ private ToolRegistryStepContextProvider _stepContextProvider = null!;
+
+ public bool Recovered { get; private set; }
+
+ public IReadOnlyList ResumedDerivations { get; private set; } = [];
+
+ private AutomindHost(HostOptions options)
+ {
+ _scheduler = PhysicalScheduler.Create();
+ _store = FileQueryEngineStateStore.Open(options.DataDirectory);
+ Conversations = new ConversationCatalog(_store);
+ Rules = new RuleCatalogStore(_store);
+ Docs = new DocCatalogStore(_store);
+ _renderer = new TraceRenderer { Verbose = options.Verbose };
+ _chaos = Environment.GetEnvironmentVariable("AUTOMIND_CHAOS");
+ }
+
+ public static async Task StartAsync(HostOptions options)
+ {
+ var host = new AutomindHost(options);
+
+ var tools = PrimitiveTools.CreateDefault();
+ options.ConfigureTools?.Invoke(tools);
+
+ // Captured BEFORE anything writes to the store: persisting the MCP specs below makes a
+ // brand-new store non-empty, and deciding create-vs-recover afterwards sent a fresh
+ // directory down the RECOVER path with no engine state to recover (caught live).
+ var isFreshStore = host._store.IsEmpty;
+
+ // Recovery re-issues in-flight tool calls by URI, and "kill and re-run to resume" must
+ // not require repeating the flags — so the bridged server commands persist with the
+ // data directory and reconnect on a resume that omits --mcp (review finding).
+ var mcpCatalog = new McpCatalogStore(host._store);
+ var mcpServers = options.McpServers;
+
+ if (mcpServers is { Count: > 0 })
+ {
+ await mcpCatalog.SaveAsync(mcpServers);
+ }
+ else if (!isFreshStore && mcpCatalog.Servers() is { Count: > 0 } recorded)
+ {
+ mcpServers = recorded;
+ AnsiConsole.MarkupLineInterpolated($"[grey]reconnecting {recorded.Count} MCP server(s) recorded for this data dir[/]");
+ }
+
+ // MCP servers bridge in AFTER the primitives, and primitives win name collisions —
+ // the papers' instruction set stays stable no matter what a server advertises.
+ if (mcpServers is { Count: > 0 })
+ {
+ var allowlist = options.McpToolFilter is { Length: > 0 } filter
+ ? new HashSet(filter.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
+ : null;
+
+ host._mcpBridge = await Automind.Mcp.McpToolBridge.ConnectAsync(
+ mcpServers,
+ allowlist,
+ message => AnsiConsole.MarkupLineInterpolated($"[yellow]{message}[/]"));
+
+ var bridged = 0;
+
+ foreach (var tool in host._mcpBridge.Tools)
+ {
+ if (tools.Resolve(ToolRegistry.UriFor(tool.Signature.Name)) is not null)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[yellow]MCP tool '{tool.Signature.Name}' collides with an existing predicate — skipped[/]");
+ continue;
+ }
+
+ tools.Add(tool);
+ bridged++;
+ }
+
+ if (bridged > 12)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[yellow]{bridged} MCP tools bridged — a prompt this wide strains an 8B; consider --mcp-tools to allowlist the ones you need[/]");
+ }
+ else if (bridged > 0)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[grey]{bridged} MCP tool(s) bridged as Universalis predicates[/]");
+ }
+ }
+
+ var chat = new OllamaApiClient(new Uri(options.OllamaEndpoint), options.Model);
+
+ host._stepContextProvider = new ToolRegistryStepContextProvider(tools);
+
+ // Rehydrate the learned-rule library from the durable catalog, then any seed rules
+ // (seeds come last so they shadow — the papers' rules-over-tools story).
+ foreach (var stored in host.Rules.All())
+ {
+ host._stepContextProvider.AddRule(stored.Definition);
+ }
+
+ foreach (var seed in options.SeedRules ?? [])
+ {
+ host._stepContextProvider.AddRule(seed);
+ }
+
+ var services = new AutomindServices(
+ new DerivationStep(),
+ host._stepContextProvider,
+ new OllamaLlmService(chat, options.Model),
+ tools);
+
+ if (isFreshStore)
+ {
+ using var activity = Automind.Reaqtor.Telemetry.AutomindDiagnostics.ActivitySource.StartActivity("engine.create");
+ host._engine = await AutomindEngineFactory.CreateNewAsync(
+ host._store, host._scheduler, services.ToDictionary(), host._iemgr);
+ }
+ else
+ {
+ using var activity = Automind.Reaqtor.Telemetry.AutomindDiagnostics.ActivitySource.StartActivity("engine.recover");
+ host.Recovered = true;
+
+ // Recovery order: topics (and their renderers) must exist BEFORE the engine
+ // recovers — recovered egress observers resolve topics inside SetContext.
+ var active = host.Conversations.Active();
+
+ foreach (var record in host.Conversations.All())
+ {
+ host.Attach(record.DerivationId, record.Topic);
+ }
+
+ host._engine = await AutomindEngineFactory.RecoverAsync(
+ host._store, host._scheduler, services.ToDictionary(), host._iemgr);
+
+ host.ResumedDerivations = [.. active.Select(r => r.DerivationId)];
+ activity?.SetTag("automind.recover.in_flight", host.ResumedDerivations.Count);
+ activity?.SetTag("automind.recover.conversations", host.Conversations.All().Count);
+ }
+
+ host._checkpoints = new CheckpointCoordinator(host._engine);
+
+ await host.StartMemoryAsync(options);
+
+ return host;
+ }
+
+ ///
+ /// Stands up the virtual-memory pager (in-process ONNX embeddings — no Ollama involvement)
+ /// and indexes the durable rule library and document store. Silently absent when the model
+ /// files aren't fetched yet.
+ ///
+ private async Task StartMemoryAsync(HostOptions options)
+ {
+ var modelDirectory = Environment.GetEnvironmentVariable("AUTOMIND_EMBEDDINGS")
+ ?? Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "models", "bge-micro-v2");
+
+ try
+ {
+ Memory = await Automind.Memory.OnnxMemoryPager.TryCreateAsync(Path.GetFullPath(modelDirectory));
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[yellow]memory pager unavailable: {ex.Message}[/]");
+ return;
+ }
+
+ if (Memory is null)
+ {
+ AnsiConsole.MarkupLine("[grey]memory pager off — run scripts\\fetch-embedding-model.ps1 to enable RAG[/]");
+ return;
+ }
+
+ var indexed = 0;
+
+ foreach (var rule in Rules.All())
+ {
+ var definition = rule.Definition;
+ await Memory.IndexAsync(new Automind.Memory.MemoryChunk(
+ "rule:" + rule.Name,
+ Automind.Memory.MemoryChunk.RuleKind,
+ rule.Name,
+ definition.Signature.Description + "\n" + definition.HeadProse));
+ indexed++;
+ }
+
+ foreach (var doc in Docs.All())
+ {
+ foreach (var chunk in Automind.Memory.OnnxMemoryPager.ChunkDocument(doc.Name, doc.Text))
+ {
+ await Memory.IndexAsync(chunk);
+ indexed++;
+ }
+ }
+
+ AnsiConsole.MarkupLineInterpolated($"[grey]memory pager on — {indexed} chunk(s) indexed (in-process ONNX embeddings)[/]");
+ }
+
+ /// Ingests a document into durable storage and the vector index.
+ public async Task LearnDocumentAsync(string name, string text)
+ {
+ await Docs.SaveAsync(name, text);
+
+ if (Memory is not null)
+ {
+ foreach (var chunk in Automind.Memory.OnnxMemoryPager.ChunkDocument(name, text))
+ {
+ await Memory.IndexAsync(chunk);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- asking
+
+ public Task AskAsync(string question, string? learnRuleName = null, bool modeB = false)
+ {
+ var envelope = QuestionEnvelope.ForText(question) with
+ {
+ LearnRuleOnSuccess = learnRuleName is not null,
+ RuleName = learnRuleName,
+ ModeB = modeB,
+ };
+
+ return AskAsync(envelope);
+ }
+
+ public async Task AskAsync(QuestionEnvelope envelope)
+ {
+ // RAG as virtual memory: the ENGINE pages relevant knowledge into the context — recalled
+ // once per question and carried in the durable envelope (deterministic replay).
+ if (Memory is not null)
+ {
+ var recalled = await Memory.RecallAsync(envelope.Text, top: 4);
+
+ var ruleNames = recalled
+ .Where(c => c.Kind == Automind.Memory.MemoryChunk.RuleKind)
+ .Select(c => c.Title)
+ .ToImmutableArray();
+
+ var chunks = recalled
+ .Where(c => c.Kind == Automind.Memory.MemoryChunk.DocKind)
+ .Select(c => new RecalledChunk(c.Title, c.Text))
+ .ToImmutableArray();
+
+ envelope = envelope with
+ {
+ Context = chunks.IsEmpty ? envelope.Context : chunks,
+ RecalledRules = ruleNames.IsEmpty ? envelope.RecalledRules : ruleNames,
+ };
+ }
+
+ var derivationId = Guid.NewGuid().ToString("N")[..8];
+ var topic = $"automind/out/{derivationId}";
+
+ Attach(derivationId, topic);
+
+ await Conversations.UpsertAsync(new ConversationRecord(
+ derivationId, topic, envelope.Text, ConversationRecord.PendingStatus));
+
+ var ctx = AutomindClientContext.For(_engine);
+
+ await ctx.Derivation(derivationId, envelope.ToJson()).SubscribeAsync(
+ ctx.Egress(topic),
+ new Uri($"automind://derivations/{derivationId}"),
+ state: null,
+ CancellationToken.None);
+
+ _checkpoints.Request();
+
+ return derivationId;
+ }
+
+ public Task WaitForAsync(string derivationId) =>
+ _completions.GetOrAdd(derivationId, _ => new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously)).Task;
+
+ private void Attach(string derivationId, string topic)
+ {
+ _completions.GetOrAdd(derivationId, _ => new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously));
+
+ // Terminal signals must resolve the waiter too (review finding): a recovered derivation
+ // whose answer was already delivered-and-drained before a kill re-emits ONLY OnCompleted
+ // — swallowing it left `ask` hung forever and the conversation permanently 'pending'.
+ // Fallback-only: after a normal Answer/Failed payload the waiter is already resolved and
+ // the trailing OnCompleted must not rewrite the persisted status.
+ _iemgr.GetOrCreateSubject(topic)
+ .Subscribe(new DelegateObserver<(long SequenceId, DerivationOutput Item)>(
+ v => OnOutput(derivationId, v.Item),
+ onCompleted: () =>
+ {
+ if (!_terminalSeen.ContainsKey(derivationId))
+ {
+ Complete(
+ derivationId,
+ new DerivationResult(derivationId, true, "(completed in a previous run — its answer was already delivered)"),
+ ConversationRecord.CompletedStatus);
+ }
+ },
+ onError: ex =>
+ {
+ if (!_terminalSeen.ContainsKey(derivationId))
+ {
+ Complete(derivationId, new DerivationResult(derivationId, false, ex.Message), ConversationRecord.FailedStatus);
+ }
+ }));
+ }
+
+ private void OnOutput(string derivationId, DerivationOutput output)
+ {
+ _renderer.Render(output);
+
+ // Per-step checkpointing: every observed output marks progress worth persisting.
+ _checkpoints.Request();
+
+ MaybeChaosKill(output);
+
+ switch (output.Kind)
+ {
+ case DerivationOutput.AnswerKind:
+ _terminalSeen[derivationId] = true;
+ Complete(derivationId, new DerivationResult(derivationId, true, output.PayloadJson), ConversationRecord.CompletedStatus);
+ break;
+
+ case DerivationOutput.FailedKind:
+ _terminalSeen[derivationId] = true;
+ Complete(derivationId, new DerivationResult(derivationId, false, output.PayloadJson), ConversationRecord.FailedStatus);
+ break;
+
+ case DerivationOutput.RuleKind:
+ OnRuleLearned(output.PayloadJson);
+ break;
+ }
+ }
+
+ /// Persists a learned rule and makes it immediately invocable by the next step.
+ private void OnRuleLearned(string payloadJson)
+ {
+ Task.Run(async () =>
+ {
+ using var activity = Automind.Reaqtor.Telemetry.AutomindDiagnostics.ActivitySource.StartActivity("rule.define");
+
+ try
+ {
+ var wrapper = System.Text.Json.JsonDocument.Parse(payloadJson);
+ var name = wrapper.RootElement.GetProperty("name").GetString()!;
+ var inner = System.Text.Json.JsonDocument.Parse(wrapper.RootElement.GetProperty("bonsai").GetString()!);
+ var ir = inner.RootElement.GetProperty("ir").GetString()!;
+ var bonsai = inner.RootElement.GetProperty("bonsai").GetString()!;
+
+ activity?.SetTag("automind.rule.name", name);
+ activity?.SetTag("automind.rule.bonsai_bytes", bonsai.Length);
+
+ await Rules.SaveAsync(name, ir, bonsai);
+ _stepContextProvider.AddRule(Universalis.Core.Ir.IrJson.DeserializeRule(ir));
+
+ AnsiConsole.MarkupLineInterpolated($"[blue]★ rule '{name}' saved to the durable library[/]");
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
+ AnsiConsole.MarkupLineInterpolated($"[red]rule persistence failed: {ex.Message}[/]");
+ }
+ });
+ }
+
+ private void Complete(string derivationId, DerivationResult result, string status)
+ {
+ Task.Run(async () =>
+ {
+ try
+ {
+ await Conversations.SetStatusAsync(derivationId, status);
+ await _checkpoints.ForceAsync();
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[red]finalization error: {ex.Message}[/]");
+ }
+ finally
+ {
+ _completions.GetOrAdd(derivationId, _ => new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously)).TrySetResult(result);
+ }
+ });
+ }
+
+ // ---------------------------------------------------------------- chaos
+
+ ///
+ /// AUTOMIND_CHAOS=tool kills after the first tool invocation is durably checkpointed
+ /// (state: AwaitingTools, call in flight). AUTOMIND_CHAOS=segment:N kills after the
+ /// N-th generation segment (state: Synthesizing, LLM request in flight). The kill is
+ /// — no unload, no cleanup: a true crash.
+ ///
+ private void MaybeChaosKill(DerivationOutput output)
+ {
+ if (_chaos is null || output.Kind != DerivationOutput.TraceKind)
+ {
+ return;
+ }
+
+ TraceEvent trace;
+ try
+ {
+ trace = TraceEvent.FromJson(output.PayloadJson);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return;
+ }
+
+ var triggered = _chaos.ToLowerInvariant() switch
+ {
+ "tool" => trace is ToolInvoked,
+ var s when s.StartsWith("segment:", StringComparison.Ordinal) &&
+ int.TryParse(s["segment:".Length..], out var n) =>
+ trace is SegmentReceived && Interlocked.Increment(ref _chaosSegments) >= n,
+ _ => false,
+ };
+
+ if (!triggered)
+ {
+ return;
+ }
+
+ Task.Run(async () =>
+ {
+ await _checkpoints.ForceAsync(); // make the crash point durable, then die
+ AnsiConsole.MarkupLine("[red bold]☠ AUTOMIND_CHAOS: killing the process now (state is checkpointed)[/]");
+ Console.Out.Flush();
+ Environment.FailFast("AUTOMIND_CHAOS kill");
+ });
+ }
+
+ // ---------------------------------------------------------------- lifecycle
+
+ public string StoreDebugView => _store.DebugView;
+
+ public int CheckpointsTaken => _checkpoints.CheckpointsTaken;
+
+ public async ValueTask DisposeAsync()
+ {
+ await _checkpoints.DisposeAsync();
+ await _engine.CheckpointAsync();
+ await _engine.UnloadAsync();
+ _engine.Dispose();
+ _scheduler.Dispose();
+ Memory?.Dispose();
+ _renderer.Dispose(); // stops the heartbeat timer
+
+ if (_mcpBridge is not null)
+ {
+ await _mcpBridge.DisposeAsync(); // shuts down the stdio child processes
+ }
+ }
+
+ private sealed class DelegateObserver : IObserver
+ {
+ private readonly Action _onNext;
+ private readonly Action? _onCompleted;
+ private readonly Action? _onError;
+
+ public DelegateObserver(Action onNext, Action? onCompleted = null, Action? onError = null)
+ {
+ _onNext = onNext;
+ _onCompleted = onCompleted;
+ _onError = onError;
+ }
+
+ public void OnCompleted() => _onCompleted?.Invoke();
+
+ public void OnError(Exception error) => _onError?.Invoke(error);
+
+ public void OnNext(T value) => _onNext(value);
+ }
+}
diff --git a/src/Automind.Cli/CheckpointCoordinator.cs b/src/Automind.Cli/CheckpointCoordinator.cs
new file mode 100644
index 0000000..d9017bd
--- /dev/null
+++ b/src/Automind.Cli/CheckpointCoordinator.cs
@@ -0,0 +1,113 @@
+using System.Threading.Channels;
+
+using Reaqtor.Shebang.Service;
+
+namespace Automind.Cli;
+
+///
+/// The host-side checkpoint policy: per-step (every derivation output requests one, debounced
+/// 100 ms) plus a 5-second safety timer. Serialized — the engine tolerates only one checkpoint
+/// at a time — and never runs on the engine scheduler (which checkpointing must pause).
+///
+public sealed class CheckpointCoordinator : IAsyncDisposable
+{
+ private readonly SimplerCheckpointingQueryEngine _engine;
+ private readonly Channel _signal = Channel.CreateBounded(
+ new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
+ private readonly CancellationTokenSource _cts = new();
+ private readonly SemaphoreSlim _gate = new(1, 1); // the engine rejects overlapping checkpoints
+ private readonly Task _loop;
+
+ public int CheckpointsTaken { get; private set; }
+
+ public event Action? CheckpointCompleted;
+
+ public CheckpointCoordinator(SimplerCheckpointingQueryEngine engine)
+ {
+ _engine = engine;
+ _loop = Task.Run(RunAsync);
+ }
+
+ /// Requests a checkpoint soon (debounced/coalesced).
+ public void Request() => _signal.Writer.TryWrite(0);
+
+ ///
+ /// Takes a checkpoint NOW and returns when it is durable — the chaos kill waits on this.
+ /// All checkpoint requests (loop, completion, chaos) serialize on one gate: the engine
+ /// throws on overlapping checkpoints (observed live from Complete() racing the loop).
+ ///
+ public async Task ForceAsync()
+ {
+ await _gate.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ using var activity = Automind.Reaqtor.Telemetry.AutomindDiagnostics.ActivitySource.StartActivity("engine.checkpoint");
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ await _engine.CheckpointAsync().ConfigureAwait(false);
+ stopwatch.Stop();
+
+ CheckpointsTaken++;
+ activity?.SetTag("automind.checkpoint.number", CheckpointsTaken);
+ Automind.Reaqtor.Telemetry.AutomindDiagnostics.CheckpointDuration.Record(stopwatch.Elapsed.TotalMilliseconds);
+ CheckpointCompleted?.Invoke(stopwatch.Elapsed);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task RunAsync()
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
+
+ var timerTask = Task.Run(async () =>
+ {
+ while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
+ {
+ Request();
+ }
+ });
+
+ try
+ {
+ while (await _signal.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
+ {
+ _signal.Reader.TryRead(out _);
+
+ // Debounce: coalesce the burst of outputs one step produces.
+ await Task.Delay(100, _cts.Token).ConfigureAwait(false);
+ _signal.Reader.TryRead(out _);
+
+ try
+ {
+ await ForceAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ Spectre.Console.AnsiConsole.MarkupLineInterpolated($"[red]checkpoint failed: {ex.Message}[/]");
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown.
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+
+ try
+ {
+ await _loop.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+
+ _cts.Dispose();
+ }
+}
diff --git a/src/Automind.Cli/Demos.cs b/src/Automind.Cli/Demos.cs
new file mode 100644
index 0000000..110ff24
--- /dev/null
+++ b/src/Automind.Cli/Demos.cs
@@ -0,0 +1,308 @@
+using System.Collections.Immutable;
+
+using Automind.Kernel.Contract;
+using Automind.Tools;
+
+using Universalis.Core.Ir;
+using Universalis.Core.Parsing;
+
+namespace Automind.Cli;
+
+/// One runnable scenario derived from the papers' worked examples.
+public sealed record DemoScenario(
+ string Name,
+ string Title,
+ string Concept,
+ string[] Blurb,
+ Action? Tools,
+ IReadOnlyList? SeedRules,
+ Func CreateEnvelope,
+ Func? AfterNotes)
+{
+ /// A refused derivation IS this demo's success (e.g. a pre-condition violation).
+ public bool ExpectFailure { get; init; }
+}
+
+public static class Demos
+{
+ public static IReadOnlyList All =>
+ [
+ WeatherRule(),
+ BulkPdf(),
+ Contracts(),
+ ContractsViolation(),
+ BtcDecision(),
+ TeamSelection(),
+ ];
+
+ public static DemoScenario? Find(string name) =>
+ All.FirstOrDefault(d => string.Equals(d.Name, name, StringComparison.OrdinalIgnoreCase));
+
+ // ================================================================ paper 1: the composed WEATHER rule
+
+ private static DemoScenario WeatherRule()
+ {
+ // The canonical literate-Prolog rule from paper 1: WEATHER is DEFINED in Universalis as
+ // a chain of three primitive facts, with JSON patterns digging the fields out of the
+ // messy API responses. The stored rule SHADOWS the primitive WEATHER tool.
+ var rule = new RuleDefinition(
+ new RuleSignature("WEATHER", [
+ new RuleParam("city", ParamMode.In),
+ new RuleParam("weather", ParamMode.Out),
+ ], "current weather via the National Weather Service (geo-code, point lookup, forecast fetch)"),
+ "Find the current weather in a city using the National Weather Service API",
+ new UniversalisProgram(
+ [
+ new Comment("To find the current weather, we first need the coordinates of the city "),
+ Hedge("GEO_CODE(@city, @lat, @lon)"),
+ new Comment(". Given the coordinates, we look up the forecast URL "),
+ Hedge("WEATHER_GOV(@lat, @lon, { ... \"forecast\": @url ... })"),
+ new Comment(", and finally we fetch the detailed forecast "),
+ Hedge("HTTP_GET(@url, { ... \"detailedForecast\": @weather ... })"),
+ new Comment("."),
+ ], [], []));
+
+ return new DemoScenario(
+ "weather-rule",
+ "The composed WEATHER rule (paper 1)",
+ "rules as new tools · JSON pattern matching · zero LLM calls inside a rule",
+ [
+ "Paper 1 defines WEATHER not as a primitive but as a Universalis rule chaining",
+ "GEO_CODE → WEATHER_GOV → HTTP_GET, destructuring each nested API response with",
+ "{ ... \"field\": @var ... } patterns. Here that rule is pre-stored and SHADOWS the",
+ "primitive WEATHER tool. Watch: one rule invocation → three tool calls, no LLM",
+ "between them — and the answer is the NWS-style detailed forecast, not the",
+ "primitive tool's short string (proof the rule ran).",
+ ],
+ tools => tools.Add(DemoTools.GeoCode()).Add(DemoTools.WeatherGov()).Add(DemoTools.HttpGet()),
+ [rule],
+ _ => QuestionEnvelope.ForText("What is the current weather in Palo Alto?"),
+ AfterNotes: null);
+ }
+
+ // ================================================================ paper 2: loopless bulk processing
+
+ private static DemoScenario BulkPdf()
+ {
+ return new DemoScenario(
+ "bulk-pdf",
+ "Convert all files to PDF (paper 2)",
+ "loopless programming · implicit zip lifting over collections",
+ [
+ "Paper 2's bulk-processing example: TO_PDF converts ONE file, but the model calls",
+ "it once with a LIST — no loop, no map. The engine's zip lifting fans the single",
+ "hedge out into one invocation per file (watch the ⚙ TO_PDF(...) ×N trace) and",
+ "binds the outputs back as a list. Real stub .pdf files appear on disk.",
+ ],
+ tools => tools.Add(DemoTools.ListFiles()).Add(DemoTools.ToPdf()),
+ SeedRules: null,
+ dataDir =>
+ {
+ var docs = Path.Combine(dataDir, "docs");
+ Directory.CreateDirectory(docs);
+ File.WriteAllText(Path.Combine(docs, "notes.txt"), "meeting notes\n");
+ File.WriteAllText(Path.Combine(docs, "report.txt"), "quarterly report\n");
+ File.WriteAllText(Path.Combine(docs, "summary.txt"), "executive summary\n");
+
+ foreach (var stale in Directory.GetFiles(docs, "*.pdf"))
+ {
+ File.Delete(stale);
+ }
+
+ // The path rides in σ as @dir (the papers' live-programming inputs) — the model
+ // manipulates the NAME and never has to re-type a fragile Windows path.
+ return QuestionEnvelope.ForText("Convert all files in the directory @dir to PDF.") with
+ {
+ InitialBindings = new Dictionary
+ {
+ ["dir"] = System.Text.Json.JsonSerializer.Serialize(docs),
+ }.ToImmutableDictionary(),
+ };
+ },
+ dataDir =>
+ {
+ var docs = Path.Combine(dataDir, "docs");
+ var pdfs = Directory.GetFiles(docs, "*.pdf").Select(Path.GetFileName).OrderBy(f => f).ToArray();
+
+ return
+ [
+ $"files now in {docs}:",
+ .. Directory.GetFiles(docs).Select(f => " " + Path.GetFileName(f)).OrderBy(s => s),
+ pdfs.Length > 0
+ ? $"→ {pdfs.Length} PDF(s) created by ONE hedge, fanned out by zip lifting."
+ : "→ no PDFs were created (the derivation likely failed — see the trace).",
+ ];
+ });
+ }
+
+ // ================================================================ paper 2: contracts
+
+ private static DemoScenario Contracts()
+ {
+ return new DemoScenario(
+ "contracts",
+ "Apples with pre/post-conditions (paper 2)",
+ "contracts as AI safety · pre gates the run · post verifies the result",
+ [
+ "Paper 2 attaches data-validation-style contracts to the apples question. The",
+ "pre-condition (the buying price must be positive) is checked BEFORE any LLM",
+ "call; the post-condition re-derives the profit formula and verifies the model's",
+ "answer against it. Run `demo contracts-violation` to see the pre-condition",
+ "refuse bad input without spending a single token.",
+ ],
+ Tools: null,
+ SeedRules: null,
+ _ => new QuestionEnvelope(
+ "Alice bought a kilo of apples for $B and sold them for $S. How much percent profit or loss did Alice make? Name the result @P.",
+ InitialBindings: new Dictionary { ["B"] = "10", ["S"] = "17" }.ToImmutableDictionary(),
+ ExpectedOutputs: ["P"],
+ LearnRuleOnSuccess: false,
+ Pre:
+ [
+ new ContractClauseText("@B > 0", "the buying price must be greater than 0 — Alice paid a positive amount for the apples"),
+ new ContractClauseText("@S >= 0", "the selling price must be non-negative"),
+ ],
+ Post:
+ [
+ new ContractClauseText("@P == ((@S - @B) / @B) * 100", "the reported percentage must equal the profit formula"),
+ ]),
+ AfterNotes: null);
+ }
+
+ /// The violation variant, exposed as its own name for discoverability.
+ public static DemoScenario ContractsViolation() => Contracts() with
+ {
+ Name = "contracts-violation",
+ Title = "Pre-condition violation (paper 2)",
+ Blurb =
+ [
+ "The same apples question with B=0: the pre-condition @B > 0 fails, so the",
+ "derivation is refused immediately — zero LLM calls, exactly like Excel rejecting",
+ "input that breaks a data-validation rule. The rationale is shown to the user.",
+ ],
+ CreateEnvelope = _ => new QuestionEnvelope(
+ "Alice got a kilo of apples for $B and sold them for $S. How much percent profit did she make?",
+ InitialBindings: new Dictionary { ["B"] = "0", ["S"] = "5" }.ToImmutableDictionary(),
+ ExpectedOutputs: ["P"],
+ LearnRuleOnSuccess: false,
+ Pre: [new ContractClauseText("@B > 0", "the buying price must be greater than 0 — Alice paid a positive amount for the apples")]),
+ ExpectFailure = true,
+ };
+
+ // ================================================================ paper 2: conditional decision
+
+ private static DemoScenario BtcDecision()
+ {
+ return new DemoScenario(
+ "btc-decision",
+ "BTC or MSFT? (paper 2)",
+ "conditionals as checklists · tools + patterns feeding a decision",
+ [
+ "Paper 2's decision example: fetch the MSFT price (nested in a messy quote blob),",
+ "find the BTC price via search, compare the totals, and decide via a checklist —",
+ "the engine evaluates the guards, runs the taken branch, and crosses out the",
+ "other. With 0.05 BTC (~$2,162) against 10 MSFT (~$4,289), Erik keeps his BTC.",
+ ],
+ tools => tools.Add(DemoTools.Stock()).Add(DemoTools.Search()),
+ SeedRules: null,
+ _ => new QuestionEnvelope(
+ // @shares, not @msft: models read "@msft" as a price/symbol and invent
+ // conversions around it (observed live: "@sharePrice is 10", COIN_TO_USD).
+ "Erik has @btc BTC. If that is enough to buy @shares MSFT shares, he should buy them; otherwise he keeps the BTC. Decide, and show the remaining BTC either way as @btcLeft.",
+ InitialBindings: new Dictionary { ["btc"] = "0.05", ["shares"] = "10" }.ToImmutableDictionary(),
+ ExpectedOutputs: ["btcLeft"],
+ LearnRuleOnSuccess: false),
+ AfterNotes: null);
+ }
+
+ // ================================================================ paper 2: the big query
+
+ private static DemoScenario TeamSelection()
+ {
+ const string Players = """
+ [{"position":"Forward","stats":120,"games":10,"age":25},
+ {"position":"Forward","stats":110,"games":8,"age":27},
+ {"position":"Midfielder","stats":105,"games":9,"age":24},
+ {"position":"Midfielder","stats":98,"games":11,"age":26},
+ {"position":"Defender","stats":92,"games":12,"age":24},
+ {"position":"Defender","stats":88,"games":10,"age":28},
+ {"position":"Goalie","stats":150,"games":2,"age":30}]
+ """;
+
+ // Paper 2's flagship query stored as an INTENTIONAL PROGRAM: the checklist compiled to
+ // IR, saved as a rule. Live 8B models channel simple queries (see the customers few-shot)
+ // but reliably mangle the five-bullet grouped form — so this demo shows the papers' other
+ // mechanism: the query as a durable named program the model merely invokes.
+ var query = new ComprehensionBlock(
+ ItemVar: "p",
+ ItemPattern: new ObjectPatternTerm([
+ new PatternField("position", new VarTerm("position")),
+ new PatternField("stats", new VarTerm("stats")),
+ new PatternField("games", new VarTerm("games")),
+ ], IsOpen: true),
+ SourceVar: "players",
+ Ops:
+ [
+ new GroupByOp("position", "Group each player [@p] by its position [@position]."),
+ new AggregateOp(AggregateFn.Mean, "stats", "averageStats", "averageStats",
+ "Determine the average stats [@stats] as [{ \"averageStats\": @averageStats }]."),
+ new AggregateOp(AggregateFn.Min, "games", "minGames", "minGames",
+ "Find the minimum games [@games] as [{ \"minGames\": @minGames }]."),
+ new CollectOp("p", "members", "members",
+ "Collect the players [@p] as [{ \"members\": @members }]."),
+ new FilterOp(new Comparison(CompareOp.Gt, new VarTerm("averageStats"), new NumTerm(100)),
+ "Keep only groups where [@averageStats > 100]."),
+ new FilterOp(new Comparison(CompareOp.Gt, new VarTerm("minGames"), new NumTerm(3)),
+ "Keep only groups where [@minGames > 3]."),
+ ],
+ IntoVar: "strongGroups");
+
+ var rule = new RuleDefinition(
+ new RuleSignature("STRONG_GROUPS", [
+ new RuleParam("players", ParamMode.In),
+ new RuleParam("strongGroups", ParamMode.Out),
+ ], "the strong position groups of a squad: average stats above 100 and minimum games above 3, with each group's members collected"),
+ "Group players by position, aggregate each group, and keep only the strong ones",
+ new UniversalisProgram(
+ [
+ new Comment("Consider each player, group by position, aggregate each group, and keep the strong ones "),
+ query,
+ new Comment("."),
+ ], [], []));
+
+ return new DemoScenario(
+ "team-selection",
+ "World Cup team selection (paper 2)",
+ "query comprehensions · group / aggregate / collect / HAVING · intentional programs",
+ [
+ "Paper 2's flagship query: group players by position, average each group's stats,",
+ "find its minimum games, COLLECT the group's players (fully nested results — the",
+ "anti-SQL superpower), and keep only strong, experienced groups. The query is",
+ "stored as an intentional program (a STRONG_GROUPS rule) that compiles to a LINQ",
+ "pipeline over the JSON rows; the model channels ONE invocation hedge and the",
+ "engine runs the whole query — no LLM calls inside. The Goalie group tops the",
+ "stats chart yet is dropped: minimum games is 2.",
+ ],
+ Tools: null,
+ [rule],
+ _ => new QuestionEnvelope(
+ "Call STRONG_GROUPS on the players in @Players and show the result.",
+ InitialBindings: new Dictionary { ["Players"] = Players }.ToImmutableDictionary(),
+ // The assembler appends "@strongGroups = " to the answer — the model
+ // narrates the call but rarely emits a display hedge for a structured result.
+ ExpectedOutputs: ["strongGroups"],
+ LearnRuleOnSuccess: false),
+ AfterNotes: null);
+ }
+
+ // ================================================================ helpers
+
+ private static HedgeItem Hedge(string content)
+ {
+ var parsed = HedgeParser.Parse(content);
+
+ return parsed.Success
+ ? new HedgeItem(parsed.Statement!, content)
+ : throw new InvalidOperationException($"demo rule hedge failed to parse: [{content}] — {parsed.Error}");
+ }
+}
diff --git a/src/Automind.Cli/OtelFileLog.cs b/src/Automind.Cli/OtelFileLog.cs
new file mode 100644
index 0000000..fcd4695
--- /dev/null
+++ b/src/Automind.Cli/OtelFileLog.cs
@@ -0,0 +1,133 @@
+using System.Diagnostics;
+using System.Text.Json;
+
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+
+namespace Automind.Cli;
+
+///
+/// A minimal JSON-lines telemetry sink: every finished span (with its automind.trace.*
+/// events — the kernel's full logic flow) and every periodic metric snapshot appends one JSON
+/// object per line. OpenTelemetry for .NET ships no file exporter; this keeps the POC
+/// self-contained and gives every run a greppable observability artifact.
+///
+public sealed class OtelFileLog : IDisposable
+{
+ private static readonly JsonSerializerOptions s_json = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
+
+ private readonly StreamWriter _writer;
+ private readonly Lock _gate = new();
+
+ public OtelFileLog(string path)
+ {
+ var full = Path.GetFullPath(path);
+
+ if (Path.GetDirectoryName(full) is { Length: > 0 } directory)
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ _writer = new StreamWriter(new FileStream(full, FileMode.Append, FileAccess.Write, FileShare.Read));
+ }
+
+ public void WriteLine(object record)
+ {
+ var line = JsonSerializer.Serialize(record, s_json);
+
+ lock (_gate)
+ {
+ _writer.WriteLine(line);
+ _writer.Flush(); // a crash mid-run must not lose the telemetry that explains it
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ _writer.Dispose();
+ }
+ }
+}
+
+internal sealed class FileActivityExporter(OtelFileLog log) : BaseExporter
+{
+ public override ExportResult Export(in Batch batch)
+ {
+ foreach (var activity in batch)
+ {
+ log.WriteLine(new
+ {
+ type = "span",
+ name = activity.DisplayName,
+ traceId = activity.TraceId.ToString(),
+ spanId = activity.SpanId.ToString(),
+ start = activity.StartTimeUtc,
+ durationMs = Math.Round(activity.Duration.TotalMilliseconds, 3),
+ status = activity.Status == ActivityStatusCode.Unset ? null : activity.Status.ToString(),
+ statusDescription = activity.StatusDescription,
+ tags = activity.TagObjects.ToDictionary(tag => tag.Key, tag => tag.Value),
+ events = activity.Events.Any()
+ ? activity.Events.Select(evt => new
+ {
+ name = evt.Name,
+ time = evt.Timestamp.UtcDateTime,
+ tags = evt.Tags.ToDictionary(tag => tag.Key, tag => tag.Value),
+ }).ToArray()
+ : null,
+ });
+ }
+
+ return ExportResult.Success;
+ }
+}
+
+internal sealed class FileMetricExporter(OtelFileLog log) : BaseExporter
+{
+ public override ExportResult Export(in Batch batch)
+ {
+ foreach (var metric in batch)
+ {
+ foreach (ref readonly var point in metric.GetMetricPoints())
+ {
+ object? value = metric.MetricType switch
+ {
+ MetricType.LongSum => point.GetSumLong(),
+ MetricType.DoubleSum => point.GetSumDouble(),
+ // min/max included: "slowest segment" questions kept falling back to span
+ // scans without them.
+ MetricType.Histogram => point.TryGetHistogramMinMaxValues(out var min, out var max)
+ ? new
+ {
+ count = point.GetHistogramCount(),
+ sum = Math.Round(point.GetHistogramSum(), 3),
+ min = (double?)Math.Round(min, 3),
+ max = (double?)Math.Round(max, 3),
+ }
+ : new
+ {
+ count = point.GetHistogramCount(),
+ sum = Math.Round(point.GetHistogramSum(), 3),
+ min = (double?)null,
+ max = (double?)null,
+ },
+ MetricType.LongGauge => point.GetGaugeLastValueLong(),
+ MetricType.DoubleGauge => point.GetGaugeLastValueDouble(),
+ _ => null,
+ };
+
+ log.WriteLine(new
+ {
+ type = "metric",
+ name = metric.Name,
+ unit = metric.Unit is { Length: > 0 } unit ? unit : null,
+ value,
+ at = DateTime.UtcNow,
+ });
+ }
+ }
+
+ return ExportResult.Success;
+ }
+}
diff --git a/src/Automind.Cli/Program.cs b/src/Automind.Cli/Program.cs
new file mode 100644
index 0000000..67eb66f
--- /dev/null
+++ b/src/Automind.Cli/Program.cs
@@ -0,0 +1,431 @@
+using System.ComponentModel;
+
+using Automind.Cli;
+
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+// The trace glyphs (⚙ ▣ ⇝ ✓ ↩) need UTF-8 — legacy Windows console code pages render them
+// as '?' / '␦'. Harmless if the handle is redirected or the encoding cannot be changed.
+try
+{
+ Console.OutputEncoding = System.Text.Encoding.UTF8;
+}
+catch (Exception)
+{
+ // non-console host; keep whatever encoding is in effect
+}
+
+var app = new CommandApp();
+
+app.Configure(config =>
+{
+ config.SetApplicationName("automind");
+
+ config.AddCommand("ask")
+ .WithDescription("Ask one question, stream the derivation live, print the answer, exit.")
+ .WithExample("ask", "\"What is the current weather in Palo Alto?\"");
+
+ config.AddCommand("repl")
+ .WithDescription("Interactive session. In-flight derivations resume automatically after a crash/restart.");
+
+ config.AddCommand("resume")
+ .WithDescription("Recover the engine and finish any in-flight derivations, then exit.");
+
+ config.AddCommand("rules")
+ .WithDescription("List the learned rules in the durable library.");
+
+ config.AddCommand("learn-doc")
+ .WithDescription("Ingest a text/markdown file into the durable virtual memory (RAG).");
+
+ config.AddCommand("demo")
+ .WithDescription("Run a worked example from the papers ('demo list' shows them all).")
+ .WithExample("demo", "weather-rule");
+
+ config.AddCommand("store")
+ .WithDescription("Dump the durable store's tables (engine artifacts + Automind catalogs).");
+});
+
+return await app.RunAsync(args);
+
+public class HostSettings : CommandSettings
+{
+ [CommandOption("--data ")]
+ [Description("Durable state directory (the thing that survives kills).")]
+ public string DataDirectory { get; init; } = "data";
+
+ [CommandOption("--endpoint ")]
+ [Description("Ollama endpoint.")]
+ public string Endpoint { get; init; } =
+ Environment.GetEnvironmentVariable("AUTOMIND_OLLAMA") ?? "http://localhost:11434";
+
+ [CommandOption("--model ")]
+ [Description("Ollama chat model.")]
+ public string Model { get; init; } =
+ Environment.GetEnvironmentVariable("AUTOMIND_OLLAMA_MODEL") ?? "granite3.3:8b";
+
+ [CommandOption("--quiet")]
+ [Description("Hide raw generation segments in the trace.")]
+ public bool Quiet { get; init; }
+
+ [CommandOption("--otel")]
+ [Description("Export OpenTelemetry traces/metrics to the console (or OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is set).")]
+ public bool OpenTelemetry { get; init; }
+
+ [CommandOption("--otel-log ")]
+ [Description("Append OpenTelemetry spans and metric snapshots to this file as JSON lines (composes with --otel).")]
+ public string? OtelLogPath { get; init; }
+
+ [CommandOption("--mcp ")]
+ [Description("Bridge an MCP stdio server's tools as Universalis predicates — one command line per flag (e.g. --mcp \"dotnet tools/McpSampleServer/bin/Debug/net10.0/McpSampleServer.dll\"). Repeatable.")]
+ public string[]? McpServers { get; init; }
+
+ [CommandOption("--mcp-tools ")]
+ [Description("Comma-separated allowlist of MCP tool names to bridge (default: all).")]
+ public string? McpTools { get; init; }
+
+ public HostOptions ToOptions() => new(DataDirectory, Endpoint, Model, Verbose: !Quiet)
+ {
+ McpServers = McpServers,
+ McpToolFilter = McpTools,
+ };
+
+ /// Builds the OTel providers when requested; dispose to flush.
+ public IDisposable? StartTelemetry()
+ {
+ if (!OpenTelemetry && OtelLogPath is null)
+ {
+ return null;
+ }
+
+ var traces = global::OpenTelemetry.Sdk.CreateTracerProviderBuilder()
+ .AddSource(Automind.Reaqtor.Telemetry.AutomindDiagnostics.SourceName);
+ var metrics = global::OpenTelemetry.Sdk.CreateMeterProviderBuilder()
+ .AddMeter(Automind.Reaqtor.Telemetry.AutomindDiagnostics.SourceName);
+
+ var fileLog = OtelLogPath is null ? null : new OtelFileLog(OtelLogPath);
+
+ if (fileLog is not null)
+ {
+ // Simple (per-span, in-order) rather than batched: this is a diagnostic log — spans
+ // are low-volume and a crash must not swallow the tail that explains it.
+ traces = traces.AddProcessor(new global::OpenTelemetry.SimpleActivityExportProcessor(new FileActivityExporter(fileLog)));
+ metrics = metrics.AddReader(new global::OpenTelemetry.Metrics.PeriodicExportingMetricReader(
+ new FileMetricExporter(fileLog), exportIntervalMilliseconds: 5000));
+ }
+
+ if (OpenTelemetry)
+ {
+ var otlp = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
+
+ if (otlp is not null)
+ {
+ traces = traces.AddOtlpExporter();
+ metrics = metrics.AddOtlpExporter();
+ }
+ else
+ {
+ traces = traces.AddConsoleExporter();
+ metrics = metrics.AddConsoleExporter();
+ }
+ }
+
+ var tracerProvider = traces.Build();
+ var meterProvider = metrics.Build();
+
+ return new Telemetry(tracerProvider, meterProvider, fileLog);
+ }
+
+ private sealed record Telemetry(IDisposable Traces, IDisposable Metrics, IDisposable? FileLog) : IDisposable
+ {
+ public void Dispose()
+ {
+ // Providers first — disposing them flushes the final metric snapshot into the sink.
+ Traces.Dispose();
+ Metrics.Dispose();
+ FileLog?.Dispose();
+ }
+ }
+}
+
+public sealed class AskSettings : HostSettings
+{
+ [CommandArgument(0, "")]
+ [Description("The question for the neural computer.")]
+ public string Question { get; init; } = "";
+
+ [CommandOption("--learn ")]
+ [Description("On success, save the derivation as a reusable rule with this name.")]
+ public string? Learn { get; init; }
+
+ [CommandOption("--mode-b")]
+ [Description("Mode B: whole-program synthesis — one structured completion returns the complete program as JSON instead of streaming hedge by hedge (the conformance floor for weaker models).")]
+ public bool ModeB { get; init; }
+}
+
+public sealed class AskCommand : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, AskSettings settings, CancellationToken cancellationToken)
+ {
+ using var telemetry = settings.StartTelemetry();
+ await using var host = await StartHostAsync(settings);
+
+ var pending = await ResumeInFlightAsync(host);
+
+ var id = await host.AskAsync(settings.Question, settings.Learn, settings.ModeB);
+ AnsiConsole.MarkupLineInterpolated($"[grey]derivation {id} started — kill this process at any point and re-run to resume[/]");
+
+ var result = await host.WaitForAsync(id);
+
+ foreach (var resumed in pending)
+ {
+ await resumed;
+ }
+
+ return result.Succeeded ? 0 : 1;
+ }
+
+ internal static async Task StartHostAsync(HostSettings settings)
+ {
+ AutomindHost host = null!;
+
+ await AnsiConsole.Status().StartAsync("starting engine…", async _ =>
+ {
+ host = await AutomindHost.StartAsync(settings.ToOptions());
+ });
+
+ if (host.Recovered)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[green]engine recovered[/] from [grey]{settings.DataDirectory}[/] — {host.ResumedDerivations.Count} in-flight derivation(s)");
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated($"[green]engine created[/] — durable state in [grey]{settings.DataDirectory}[/]");
+ }
+
+ return host;
+ }
+
+ internal static Task>> ResumeInFlightAsync(AutomindHost host)
+ {
+ var waits = new List>();
+
+ foreach (var id in host.ResumedDerivations)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[yellow]⟲ resuming derivation {id} — replaying its trace[/]");
+ waits.Add(host.WaitForAsync(id));
+ }
+
+ return Task.FromResult(waits);
+ }
+}
+
+public sealed class ReplCommand : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, HostSettings settings, CancellationToken cancellationToken)
+ {
+ using var telemetry = settings.StartTelemetry();
+ await using var host = await AskCommand.StartHostAsync(settings);
+
+ foreach (var wait in await AskCommand.ResumeInFlightAsync(host))
+ {
+ await wait; // let recovered derivations finish (their traces stream live)
+ }
+
+ AnsiConsole.MarkupLine("[grey]type a question, or 'exit' to quit (killing the process mid-derivation is encouraged — that's the demo)[/]");
+
+ while (true)
+ {
+ var question = AnsiConsole.Prompt(new TextPrompt("[bold cyan]?[/]").AllowEmpty());
+
+ if (string.IsNullOrWhiteSpace(question))
+ {
+ continue;
+ }
+
+ if (question.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+
+ var id = await host.AskAsync(question.Trim());
+ await host.WaitForAsync(id);
+ }
+ }
+}
+
+public sealed class ResumeCommand : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, HostSettings settings, CancellationToken cancellationToken)
+ {
+ using var telemetry = settings.StartTelemetry();
+ await using var host = await AskCommand.StartHostAsync(settings);
+
+ var waits = await AskCommand.ResumeInFlightAsync(host);
+
+ if (waits.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[grey]nothing to resume[/]");
+ return 0;
+ }
+
+ var results = await Task.WhenAll(waits);
+ return results.All(r => r.Succeeded) ? 0 : 1;
+ }
+}
+
+public sealed class DemoSettings : HostSettings
+{
+ [CommandArgument(0, "")]
+ [Description("Demo name, or 'list' to enumerate the available demos.")]
+ public string Name { get; init; } = "list";
+}
+
+public sealed class DemoCommand : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, DemoSettings settings, CancellationToken cancellationToken)
+ {
+ if (settings.Name.Equals("list", StringComparison.OrdinalIgnoreCase))
+ {
+ var table = new Table().RoundedBorder()
+ .AddColumn("demo").AddColumn("shows").AddColumn("from");
+
+ foreach (var scenario in Demos.All)
+ {
+ table.AddRow(scenario.Name, scenario.Concept, scenario.Title);
+ }
+
+ AnsiConsole.Write(table);
+ AnsiConsole.MarkupLine("[grey]run one with: automind demo [/]");
+ return 0;
+ }
+
+ var demo = Demos.Find(settings.Name);
+
+ if (demo is null)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[red]unknown demo '{settings.Name}' — try 'automind demo list'[/]");
+ return 1;
+ }
+
+ using var telemetry = settings.StartTelemetry();
+
+ AnsiConsole.Write(new Panel(string.Join("\n", demo.Blurb))
+ .Header($"[bold] {demo.Title} [/]")
+ .BorderColor(Color.Blue)
+ .RoundedBorder());
+
+ var options = settings.ToOptions() with
+ {
+ ConfigureTools = demo.Tools,
+ SeedRules = demo.SeedRules,
+ };
+
+ AutomindHost host = null!;
+ await AnsiConsole.Status().StartAsync("starting engine…", async _ =>
+ {
+ host = await AutomindHost.StartAsync(options);
+ });
+
+ await using (host)
+ {
+ var envelope = demo.CreateEnvelope(Path.GetFullPath(settings.DataDirectory));
+ AnsiConsole.MarkupLineInterpolated($"[bold cyan]?[/] {envelope.Text}");
+
+ var id = await host.AskAsync(envelope);
+ var result = await host.WaitForAsync(id);
+
+ foreach (var note in demo.AfterNotes?.Invoke(Path.GetFullPath(settings.DataDirectory)) ?? [])
+ {
+ AnsiConsole.MarkupLineInterpolated($"[grey]{note}[/]");
+ }
+
+ if (demo.ExpectFailure)
+ {
+ AnsiConsole.MarkupLine(result.Succeeded
+ ? "[red]→ this demo expected a refusal, but the derivation succeeded.[/]"
+ : "[green]→ the refusal above is this demo's expected outcome.[/]");
+
+ return result.Succeeded ? 1 : 0;
+ }
+
+ return result.Succeeded ? 0 : 1;
+ }
+ }
+}
+
+public sealed class LearnDocSettings : HostSettings
+{
+ [CommandArgument(0, "")]
+ [Description("Path to a text/markdown file to ingest.")]
+ public string File { get; init; } = "";
+
+ [CommandOption("--name ")]
+ [Description("Document name in the memory (defaults to the file name).")]
+ public string? Name { get; init; }
+}
+
+public sealed class LearnDocCommand : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, LearnDocSettings settings, CancellationToken cancellationToken)
+ {
+ if (!File.Exists(settings.File))
+ {
+ AnsiConsole.MarkupLineInterpolated($"[red]file not found: {settings.File}[/]");
+ return 1;
+ }
+
+ using var telemetry = settings.StartTelemetry();
+ await using var host = await AskCommand.StartHostAsync(settings);
+
+ var name = settings.Name ?? Path.GetFileNameWithoutExtension(settings.File);
+ await host.LearnDocumentAsync(name, await File.ReadAllTextAsync(settings.File, cancellationToken));
+
+ AnsiConsole.MarkupLineInterpolated($"[green]ingested '{name}' into the durable virtual memory[/]");
+ return 0;
+ }
+}
+
+public sealed class RulesCommand : AsyncCommand
+{
+ protected override Task ExecuteAsync(CommandContext context, HostSettings settings, CancellationToken cancellationToken)
+ {
+ var store = Automind.Reaqtor.Store.FileQueryEngineStateStore.Open(settings.DataDirectory);
+ var rules = new Automind.Reaqtor.Catalog.RuleCatalogStore(store).All();
+
+ if (rules.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[grey]no learned rules yet — try: automind ask --learn myRule \"…\"[/]");
+ return Task.FromResult(0);
+ }
+
+ var table = new Table().RoundedBorder().AddColumn("rule").AddColumn("signature").AddColumn("bonsai bytes");
+
+ foreach (var rule in rules)
+ {
+ var definition = rule.Definition;
+ var signature = string.Join(", ", definition.Signature.Params.Select(p =>
+ $"{p.Name}: {(p.Mode == Universalis.Core.Ir.ParamMode.In ? "in" : "out")}"));
+
+ table.AddRow(rule.Name, signature, rule.BonsaiJson.Length.ToString());
+ }
+
+ AnsiConsole.Write(table);
+ return Task.FromResult(0);
+ }
+}
+
+public sealed class StoreCommand : AsyncCommand
+{
+ protected override Task ExecuteAsync(CommandContext context, HostSettings settings, CancellationToken cancellationToken)
+ {
+ var store = Automind.Reaqtor.Store.FileQueryEngineStateStore.Open(settings.DataDirectory);
+
+ AnsiConsole.WriteLine(store.DebugView);
+
+ return Task.FromResult(0);
+ }
+}
diff --git a/src/Automind.Cli/TraceRenderer.cs b/src/Automind.Cli/TraceRenderer.cs
new file mode 100644
index 0000000..affb394
--- /dev/null
+++ b/src/Automind.Cli/TraceRenderer.cs
@@ -0,0 +1,248 @@
+using Automind.Kernel.Contract;
+using Automind.Reaqtor.Reactive;
+
+using Spectre.Console;
+
+namespace Automind.Cli;
+
+///
+/// The live-programming view: renders derivation outputs as they stream from the engine —
+/// the model's prose dimmed, hedges and bindings highlighted, ⇝ displays emphasized (values
+/// the USER sees but the model never does), backtracks and repairs annotated. Generation wall
+/// time is made visible (measured live: the same derivation swings 6–8× with Ollama's mood):
+/// slow segments are annotated, a heartbeat line breaks long silences, and every answer gets
+/// an LLM-time footer.
+///
+public sealed class TraceRenderer : IDisposable
+{
+ private readonly Lock _gate = new();
+ private readonly Timer _heartbeat;
+
+ private DateTime _lastEventAt;
+ private bool _terminal;
+ private int _segments;
+ private double _generationTotalSeconds;
+ private double _generationMaxSeconds;
+
+ public TraceRenderer()
+ {
+ _lastEventAt = DateTime.UtcNow;
+ _heartbeat = new Timer(_ => Heartbeat(), null, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15));
+ }
+
+ public bool Verbose { get; init; } = true;
+
+ public void Dispose() => _heartbeat.Dispose();
+
+ public void Render(DerivationOutput output)
+ {
+ lock (_gate)
+ {
+ var now = DateTime.UtcNow;
+ var gap = now - _lastEventAt;
+ _lastEventAt = now;
+
+ switch (output.Kind)
+ {
+ case DerivationOutput.TraceKind:
+ _terminal = false;
+ RenderTrace(output, gap);
+ break;
+
+ case DerivationOutput.AnswerKind:
+ AnsiConsole.Write(new Panel(Markup.FromInterpolated($"[bold green]{output.PayloadJson}[/]"))
+ .Header($"[green] answer · {output.DerivationId} [/]")
+ .BorderColor(Color.Green)
+ .RoundedBorder());
+ FinishDerivation();
+ break;
+
+ case DerivationOutput.FailedKind:
+ AnsiConsole.Write(new Panel(Markup.FromInterpolated($"[bold red]{output.PayloadJson}[/]"))
+ .Header($"[red] failed · {output.DerivationId} [/]")
+ .BorderColor(Color.Red)
+ .RoundedBorder());
+ FinishDerivation();
+ break;
+
+ case DerivationOutput.RuleKind:
+ string ruleName;
+ try
+ {
+ ruleName = System.Text.Json.JsonDocument.Parse(output.PayloadJson)
+ .RootElement.GetProperty("name").GetString() ?? "?";
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ ruleName = "?";
+ }
+
+ AnsiConsole.MarkupLineInterpolated($"[blue]★ rule learned: {ruleName}[/]");
+ break;
+ }
+ }
+ }
+
+ /// Long silences get a visible pulse — a healthy slow generation must not read as a hang.
+ private void Heartbeat()
+ {
+ lock (_gate)
+ {
+ if (_terminal)
+ {
+ return;
+ }
+
+ var quiet = DateTime.UtcNow - _lastEventAt;
+
+ if (quiet > TimeSpan.FromSeconds(20))
+ {
+ AnsiConsole.MarkupLineInterpolated($" [grey]⋯ still generating ({(int)quiet.TotalSeconds} s since the last step)[/]");
+ }
+ }
+ }
+
+ private void FinishDerivation()
+ {
+ if (_segments > 0)
+ {
+ AnsiConsole.MarkupLineInterpolated(
+ $"[grey]LLM: {_segments} segment(s), {_generationTotalSeconds:n0} s total, slowest {_generationMaxSeconds:n0} s[/]");
+ }
+
+ _terminal = true;
+ _segments = 0;
+ _generationTotalSeconds = 0;
+ _generationMaxSeconds = 0;
+ }
+
+ private void RenderTrace(DerivationOutput output, TimeSpan gap)
+ {
+ TraceEvent trace;
+ try
+ {
+ trace = TraceEvent.FromJson(output.PayloadJson);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ AnsiConsole.MarkupLineInterpolated($" [grey]{output.PayloadJson}[/]");
+ return;
+ }
+
+ // A segment's arrival gap IS its generation wall time (the burst of execution traces
+ // that follows a segment lands within milliseconds of it).
+ if (trace is SegmentReceived)
+ {
+ _segments++;
+ _generationTotalSeconds += gap.TotalSeconds;
+ _generationMaxSeconds = Math.Max(_generationMaxSeconds, gap.TotalSeconds);
+ }
+
+ switch (trace)
+ {
+ case SegmentReceived segment when Verbose:
+ var waited = gap.TotalSeconds >= 10 ? $" ({(int)gap.TotalSeconds} s)" : "";
+ AnsiConsole.MarkupLineInterpolated($" [grey]⋯ {Truncate(segment.Text, 160)}{waited}[/]");
+ break;
+
+ case VariableBound bound:
+ AnsiConsole.MarkupLineInterpolated($" [cyan]@{bound.Name}[/] ← [white]{Truncate(bound.ValueJson, 100)}[/] [grey](via {bound.Source})[/]");
+ break;
+
+ case DisplayShown display:
+ AnsiConsole.MarkupLineInterpolated($" [bold yellow]⇝ {display.Formatted}[/]");
+ break;
+
+ case ToolInvoked tool:
+ var lifted = tool.Invocations > 1 ? $" ×{tool.Invocations}" : "";
+ AnsiConsole.MarkupLineInterpolated($" [magenta]⚙ {tool.Tool}({Truncate(tool.ArgsJson, 80)}){lifted}[/]");
+ break;
+
+ case GuardEvaluated guard:
+ // Verdict styling lives in the literal template part — interpolated values are escaped.
+ if (guard.Result)
+ {
+ AnsiConsole.MarkupLineInterpolated($" [blue]? {guard.GuardText}[/] → [green]true[/]");
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated($" [blue]? {guard.GuardText}[/] → [red]false[/]");
+ }
+
+ break;
+
+ case BranchTaken taken:
+ AnsiConsole.MarkupLineInterpolated($" [green]▶ branch {taken.Branch + 1} taken[/]");
+ break;
+
+ case BranchCrossedOut crossed:
+ AnsiConsole.MarkupLineInterpolated($" [grey strikethrough]✗ branch {crossed.Branch + 1}: {crossed.GuardText}[/]");
+ break;
+
+ case BacktrackStarted backtrack:
+ AnsiConsole.MarkupLineInterpolated($" [yellow]↩ backtrack (attempt {backtrack.Attempt + 1}): {Truncate(backtrack.Reason, 140)}[/]");
+ break;
+
+ case BranchAbandoned when !Verbose:
+ break;
+
+ case BranchAbandoned abandoned:
+ AnsiConsole.MarkupLineInterpolated($" [grey strikethrough]{Truncate(abandoned.DiscardedText, 120)}[/]");
+ break;
+
+ case ProtocolRepaired repaired:
+ AnsiConsole.MarkupLineInterpolated($" [grey]✎ auto-repaired ({repaired.What})[/]");
+ break;
+
+ case QueryExecuted query:
+ AnsiConsole.MarkupLineInterpolated($" [blue]∑ query → @{query.IntoVar}: {query.RowsIn} rows in, {query.RowsOut} out[/]");
+ break;
+
+ case ContractChecked contract:
+ // NB: interpolated values are markup-escaped, so the verdict styling must live
+ // in the literal part of the template.
+ if (contract.Passed)
+ {
+ AnsiConsole.MarkupLineInterpolated($" [green]✓[/] {(contract.IsPre ? "pre" : "post")}-condition holds: {Truncate(contract.ClauseText, 100)}");
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated($" [red]✗[/] {(contract.IsPre ? "pre" : "post")}-condition violated: {Truncate(contract.ClauseText, 100)}");
+ }
+
+ break;
+
+ case RuleInvoked invoked:
+ AnsiConsole.MarkupLineInterpolated($" [blue]▣ rule {invoked.Name} invoked (runs without the LLM)[/]");
+ break;
+
+ case RuleLearned learned:
+ AnsiConsole.MarkupLineInterpolated($" [blue]★ learned rule {learned.Name}: {learned.Signature}[/]");
+ break;
+
+ case DerivationFailed failed:
+ AnsiConsole.MarkupLineInterpolated($" [red]✖ {failed.Reason}[/]");
+ break;
+
+ case AnswerAssembled:
+ case CommentAdded:
+ case HedgeParsed:
+ case SegmentReceived:
+ break;
+
+ default:
+ if (Verbose)
+ {
+ AnsiConsole.MarkupLineInterpolated($" [grey]{Truncate(output.PayloadJson, 120)}[/]");
+ }
+
+ break;
+ }
+ }
+
+ private static string Truncate(string text, int max)
+ {
+ var flat = text.Replace('\n', ' ').Replace('\r', ' ');
+ return flat.Length <= max ? flat : flat[..max] + "…";
+ }
+}
diff --git a/src/Automind.Kernel/Automind.Kernel.csproj b/src/Automind.Kernel/Automind.Kernel.csproj
new file mode 100644
index 0000000..041a77a
--- /dev/null
+++ b/src/Automind.Kernel/Automind.Kernel.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Automind.Kernel/Contract/DerivationEvents.cs b/src/Automind.Kernel/Contract/DerivationEvents.cs
new file mode 100644
index 0000000..3130378
--- /dev/null
+++ b/src/Automind.Kernel/Contract/DerivationEvents.cs
@@ -0,0 +1,82 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+using Universalis.Core.Evaluation;
+using Universalis.Core.Ir;
+
+namespace Automind.Kernel.Contract;
+
+/// An input to the derivation state machine (delivered at-least-once by the substrate).
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")]
+[JsonDerivedType(typeof(QuestionReceived), "question")]
+[JsonDerivedType(typeof(LlmCompleted), "llm")]
+[JsonDerivedType(typeof(ToolSucceeded), "toolOk")]
+[JsonDerivedType(typeof(ToolFailed), "toolFail")]
+public abstract record DerivationEvent;
+
+public sealed record QuestionReceived(string QuestionJson) : DerivationEvent;
+
+///
+/// A generation segment finished. is true when the bridge cut the
+/// stream at a balanced hedge close (the ] itself excluded — the engine owns that bracket);
+/// false means the model stopped on its own (completion, or a truncated hedge).
+///
+public sealed record LlmCompleted(string RequestId, string Text, bool StoppedAtHedge, bool Truncated = false) : DerivationEvent;
+
+/// One tool invocation succeeded. holds the observable's values (0..n).
+public sealed record ToolSucceeded(string RequestId, ImmutableArray ResultsJson) : DerivationEvent;
+
+public sealed record ToolFailed(string RequestId, string Error) : DerivationEvent;
+
+/// An output of the state machine, realized by the substrate.
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")]
+[JsonDerivedType(typeof(RequestLlm), "requestLlm")]
+[JsonDerivedType(typeof(InvokeTool), "invokeTool")]
+[JsonDerivedType(typeof(EmitTrace), "trace")]
+[JsonDerivedType(typeof(EmitAnswer), "answer")]
+[JsonDerivedType(typeof(DefineRule), "defineRule")]
+[JsonDerivedType(typeof(FailedEffect), "failed")]
+public abstract record DerivationEffect;
+
+public sealed record RequestLlm(string RequestId, string PromptStateJson) : DerivationEffect;
+
+public sealed record InvokeTool(string RequestId, string ToolUri, string ArgsJson) : DerivationEffect;
+
+public sealed record EmitTrace(string TraceJson) : DerivationEffect;
+
+public sealed record EmitAnswer(string Text) : DerivationEffect;
+
+public sealed record DefineRule(string Name, string BonsaiJson) : DerivationEffect;
+
+public sealed record FailedEffect(string Reason) : DerivationEffect;
+
+/// A tool the model may call: its Universalis signature bound to an engine artifact URI.
+public sealed record ToolBinding(PredicateSignature Signature, string ToolUri, bool IsIdempotent, string Description);
+
+///
+/// The injected context snapshot for one step: tool bindings and stored rules. NOT part of the
+/// checkpointed state — the substrate rehydrates it from the catalog store on recovery.
+///
+public sealed record StepContext(
+ ImmutableArray Tools,
+ ImmutableArray Rules)
+{
+ public ISignatureCatalog BuildCatalog() => new SignatureCatalog(
+ Tools.Select(t => t.Signature)
+ .Concat(Rules.Select(r => new PredicateSignature(
+ r.Signature.Name,
+ [.. r.Signature.Params.Select(p => new PredicateParam(p.Name, p.Mode))],
+ r.Signature.Description,
+ IsRule: true))));
+
+ public ToolBinding? FindTool(string name) =>
+ Tools.FirstOrDefault(t => string.Equals(t.Signature.Name, name, StringComparison.OrdinalIgnoreCase));
+}
+
+public sealed record StepResult(DerivationState State, ImmutableArray Effects);
+
+/// The pure, deterministic derivation step: the papers' reasoning engine (control unit).
+public interface IStepFunction
+{
+ StepResult Step(DerivationState state, DerivationEvent evt, StepContext context);
+}
diff --git a/src/Automind.Kernel/Contract/DerivationState.cs b/src/Automind.Kernel/Contract/DerivationState.cs
new file mode 100644
index 0000000..5a241f9
--- /dev/null
+++ b/src/Automind.Kernel/Contract/DerivationState.cs
@@ -0,0 +1,145 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Universalis.Core.Evaluation;
+using Universalis.Core.Ir;
+using Universalis.Core.Parsing;
+
+namespace Automind.Kernel.Contract;
+
+/// The phase of a derivation — what external completion the machine is waiting for.
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")]
+[JsonDerivedType(typeof(Idle), "idle")]
+[JsonDerivedType(typeof(Synthesizing), "synthesizing")]
+[JsonDerivedType(typeof(AwaitingTools), "awaitingTools")]
+[JsonDerivedType(typeof(Completed), "completed")]
+[JsonDerivedType(typeof(FailedPhase), "failed")]
+public abstract record Phase;
+
+public sealed record Idle : Phase;
+
+/// An LLM request is outstanding.
+public sealed record Synthesizing : Phase;
+
+/// One or more tool invocations are outstanding.
+public sealed record AwaitingTools : Phase;
+
+public sealed record Completed(string Answer) : Phase;
+
+public sealed record FailedPhase(string Reason) : Phase;
+
+///
+/// One stored-rule activation: the rule body being interpreted, its program counter, and its
+/// local bindings. Rules run WITHOUT the LLM; a tool call inside a rule suspends the whole
+/// machine exactly like a top-level call (checkpointable mid-rule — this is why v1 interprets
+/// the IR rather than executing compiled trees).
+///
+public sealed record RuleFrame(
+ string RuleName,
+ ImmutableArray Body,
+ int Pc,
+ ImmutableDictionary Locals,
+ ImmutableArray CallerOutArgs,
+ ImmutableArray FormalOuts);
+
+/// An in-flight (possibly zip-lifted) tool call: signatures, out-args, and collected results.
+public sealed record PendingCall(
+ PredicateSignature Signature,
+ string ToolUri,
+ bool IsIdempotent,
+ ImmutableArray OutArgs,
+ ImmutableArray RequestIds,
+ ImmutableArray Results)
+{
+ public bool AllCollected => Results.All(r => r is not null);
+}
+
+///
+/// A backtracking anchor: everything needed to rewind to just before an LLM generation and try an
+/// alternative (the tree-of-thought search). Values are snapshots, not deltas — POC state is small.
+///
+public sealed record ChoicePoint(
+ int ProgramLength,
+ ImmutableDictionary Sigma,
+ string AssistantPrefill,
+ RecognizerState Recognizer,
+ int Attempt);
+
+public sealed record RetryBudget(
+ int MaxAttemptsPerChoicePoint,
+ int MaxBacktrackDepth,
+ int MaxLlmRequests,
+ int BacktrackDepthUsed,
+ bool ContinuationRetryUsed)
+{
+ // 32 requests: successful live derivations use ≤ ~15, but the tree-of-thought RESTART is
+ // gated at ¾ of the budget — a 24-request budget closes that gate at request 18, exactly
+ // when a teach-heavy first derivation has accumulated the notes that make a fresh start
+ // succeed (observed live: the customers query exhausted choice points at ~request 20 with
+ // the restart locked out). The tail is restart room, not doomed-roll food.
+ public static RetryBudget Default { get; } = new(3, 4, 32, 0, false);
+}
+
+///
+/// The complete, serializable state of one derivation — the checkpointed heart of the neural
+/// computer. σ is the register set; is the model-visible trace
+/// (names only, never values); is the intentional representation
+/// growing as the model hallucinates the program.
+///
+public sealed record DerivationState(
+ string ConversationId,
+ long NextSeq,
+ Phase Phase,
+ ImmutableArray PendingRequestIds,
+ QuestionEnvelope? Question,
+ ImmutableArray ProgramSoFar,
+ ImmutableDictionary Sigma,
+ ImmutableArray ChoicePoints,
+ RetryBudget Budget,
+ RecognizerState Recognizer,
+ string AssistantPrefill,
+ ImmutableArray EngineNotes,
+ PendingCall? Pending,
+ int LlmRequestCount,
+ ImmutableArray Frames)
+{
+ ///
+ /// Mode B (whole-program synthesis): the imported program still awaiting execution.
+ /// Empty when not mid-walk. Init-property (not positional) so old checkpoints deserialize.
+ ///
+ public ImmutableArray ModeBQueue { get; init; } = [];
+
+ /// Mode B: index of the next queue item to execute (walk resumes here after a tool).
+ public int ModeBPc { get; init; }
+
+ public static DerivationState New(string conversationId) => new(
+ conversationId,
+ NextSeq: 0,
+ new Idle(),
+ PendingRequestIds: [],
+ Question: null,
+ ProgramSoFar: [],
+ Sigma: ImmutableDictionary.Empty,
+ ChoicePoints: [],
+ RetryBudget.Default,
+ RecognizerState.Initial,
+ AssistantPrefill: "",
+ EngineNotes: [],
+ Pending: null,
+ LlmRequestCount: 0,
+ Frames: []);
+
+ public bool IsTerminal => Phase is Completed or FailedPhase;
+
+ private static readonly JsonSerializerOptions s_json = new()
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ public string ToJson() => JsonSerializer.Serialize(this, s_json);
+
+ public static DerivationState FromJson(string json) =>
+ JsonSerializer.Deserialize(json, s_json)
+ ?? throw new JsonException("null derivation state");
+}
diff --git a/src/Automind.Kernel/Contract/QuestionEnvelope.cs b/src/Automind.Kernel/Contract/QuestionEnvelope.cs
new file mode 100644
index 0000000..a441680
--- /dev/null
+++ b/src/Automind.Kernel/Contract/QuestionEnvelope.cs
@@ -0,0 +1,60 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+
+namespace Automind.Kernel.Contract;
+
+/// A contract clause as text: a Universalis condition plus its natural-language rationale.
+public sealed record ContractClauseText(string Condition, string Rationale);
+
+/// A page of recalled knowledge, paged into the context by the memory pager (RAG as virtual memory).
+public sealed record RecalledChunk(string Title, string Text);
+
+///
+/// The payload of a event: the user's question plus the
+/// live-programming inputs (initial σ bindings, e.g. @B=10, @S=17), the declared outputs
+/// to surface in the final answer, and optional pre/post-condition contracts — vanilla
+/// Universalis conditions, per the paper's data-validation analogy. Pre-conditions gate the run
+/// (violation = user error, no retry); post-conditions check the completed derivation
+/// (violation = the model's plan was wrong → backtrack).
+///
+public sealed record QuestionEnvelope(
+ string Text,
+ ImmutableDictionary InitialBindings,
+ ImmutableArray ExpectedOutputs,
+ bool LearnRuleOnSuccess,
+ ImmutableArray? Pre = null,
+ ImmutableArray? Post = null,
+ string? RuleName = null,
+ ImmutableArray? Context = null,
+ ImmutableArray? RecalledRules = null,
+ bool ModeB = false)
+{
+ public ImmutableArray PreClauses => Pre ?? [];
+
+ public ImmutableArray PostClauses => Post ?? [];
+
+ public ImmutableArray ContextChunks => Context ?? [];
+
+ public static QuestionEnvelope ForText(string text) => new(text, ImmutableDictionary.Empty, [], false);
+
+ public string ToJson() => JsonSerializer.Serialize(this);
+
+ public static QuestionEnvelope FromJson(string json)
+ {
+ // Tolerance: a bare string is a question with no inputs.
+ try
+ {
+ var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind == JsonValueKind.String)
+ {
+ return ForText(doc.RootElement.GetString()!);
+ }
+ }
+ catch (JsonException)
+ {
+ return ForText(json);
+ }
+
+ return JsonSerializer.Deserialize(json) ?? ForText(json);
+ }
+}
diff --git a/src/Automind.Kernel/Contract/TraceEvents.cs b/src/Automind.Kernel/Contract/TraceEvents.cs
new file mode 100644
index 0000000..6a13ccb
--- /dev/null
+++ b/src/Automind.Kernel/Contract/TraceEvents.cs
@@ -0,0 +1,77 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Automind.Kernel.Contract;
+
+///
+/// User-side trace vocabulary. Trace events MAY carry values — the paper's ⇝ is precisely
+/// "visible to the user, not the model". The CLI renders these as the live-programming view.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")]
+[JsonDerivedType(typeof(SegmentReceived), "segment")]
+[JsonDerivedType(typeof(CommentAdded), "comment")]
+[JsonDerivedType(typeof(HedgeParsed), "hedge")]
+[JsonDerivedType(typeof(ToolInvoked), "toolInvoked")]
+[JsonDerivedType(typeof(VariableBound), "bound")]
+[JsonDerivedType(typeof(DisplayShown), "display")]
+[JsonDerivedType(typeof(GuardEvaluated), "guard")]
+[JsonDerivedType(typeof(BranchTaken), "branchTaken")]
+[JsonDerivedType(typeof(BranchCrossedOut), "branchCrossed")]
+[JsonDerivedType(typeof(QueryExecuted), "query")]
+[JsonDerivedType(typeof(ContractChecked), "contract")]
+[JsonDerivedType(typeof(ProtocolRepaired), "repaired")]
+[JsonDerivedType(typeof(BacktrackStarted), "backtrack")]
+[JsonDerivedType(typeof(BranchAbandoned), "abandoned")]
+[JsonDerivedType(typeof(RuleInvoked), "ruleInvoked")]
+[JsonDerivedType(typeof(RuleLearned), "ruleLearned")]
+[JsonDerivedType(typeof(AnswerAssembled), "answerAssembled")]
+[JsonDerivedType(typeof(DerivationFailed), "derivationFailed")]
+public abstract record TraceEvent(long Seq)
+{
+ private static readonly JsonSerializerOptions s_json = new()
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ public string ToJson() => JsonSerializer.Serialize(this, s_json);
+
+ public static TraceEvent FromJson(string json) =>
+ JsonSerializer.Deserialize(json, s_json) ?? throw new JsonException("null trace event");
+}
+
+public sealed record SegmentReceived(long Seq, string Text, bool EndedAtHedge) : TraceEvent(Seq);
+
+public sealed record CommentAdded(long Seq, string Text) : TraceEvent(Seq);
+
+public sealed record HedgeParsed(long Seq, string ConcreteText, string Kind) : TraceEvent(Seq);
+
+public sealed record ToolInvoked(long Seq, string Tool, string ArgsJson, int Invocations) : TraceEvent(Seq);
+
+public sealed record VariableBound(long Seq, string Name, string ValueJson, string Source) : TraceEvent(Seq);
+
+/// The ⇝ event: a display expression's value, shown to the user only.
+public sealed record DisplayShown(long Seq, string ExprText, string Formatted) : TraceEvent(Seq);
+
+public sealed record GuardEvaluated(long Seq, int Branch, string GuardText, bool Result) : TraceEvent(Seq);
+
+public sealed record BranchTaken(long Seq, int Branch) : TraceEvent(Seq);
+
+public sealed record BranchCrossedOut(long Seq, int Branch, string GuardText) : TraceEvent(Seq);
+
+public sealed record QueryExecuted(long Seq, string IntoVar, int RowsIn, int RowsOut) : TraceEvent(Seq);
+
+public sealed record ContractChecked(long Seq, bool IsPre, string ClauseText, bool Passed) : TraceEvent(Seq);
+
+public sealed record ProtocolRepaired(long Seq, string What, string Detail) : TraceEvent(Seq);
+
+public sealed record BacktrackStarted(long Seq, string Reason, int ChoicePoint, int Attempt) : TraceEvent(Seq);
+
+public sealed record BranchAbandoned(long Seq, string DiscardedText) : TraceEvent(Seq);
+
+public sealed record RuleInvoked(long Seq, string Name) : TraceEvent(Seq);
+
+public sealed record RuleLearned(long Seq, string Name, string Signature) : TraceEvent(Seq);
+
+public sealed record AnswerAssembled(long Seq, string Text) : TraceEvent(Seq);
+
+public sealed record DerivationFailed(long Seq, string Reason) : TraceEvent(Seq);
diff --git a/src/Automind.Kernel/DerivationStep.cs b/src/Automind.Kernel/DerivationStep.cs
new file mode 100644
index 0000000..e85c006
--- /dev/null
+++ b/src/Automind.Kernel/DerivationStep.cs
@@ -0,0 +1,1809 @@
+using System.Collections.Immutable;
+
+using Automind.Kernel.Contract;
+using Automind.Kernel.Prompting;
+
+using Universalis.Core.Compilation;
+using Universalis.Core.Evaluation;
+using Universalis.Core.Ir;
+using Universalis.Core.Parsing;
+using Universalis.Core.Rendering;
+
+namespace Automind.Kernel;
+
+///
+/// The pure derivation step — the papers' reasoning engine (control unit). Consumes one event,
+/// produces the successor state plus effects. Deterministic and I/O-free: request IDs come from
+/// the state's sequence counter, retry temperature is a function of the attempt number, and time
+/// enters only through tools. Events with unknown request IDs are no-ops, making the function
+/// safe under the substrate's at-least-once redelivery.
+///
+public sealed class DerivationStep : IStepFunction
+{
+ public StepResult Step(DerivationState state, DerivationEvent evt, StepContext context)
+ {
+ if (state.IsTerminal)
+ {
+ return new StepResult(state, []);
+ }
+
+ return evt switch
+ {
+ QuestionReceived question when state.Phase is Idle =>
+ OnQuestion(state, question, context),
+
+ LlmCompleted llm when state.Phase is Synthesizing && state.PendingRequestIds.Contains(llm.RequestId) =>
+ OnLlmCompleted(state, llm, context),
+
+ ToolSucceeded ok when state.Phase is AwaitingTools && state.Pending is not null && state.Pending.RequestIds.Contains(ok.RequestId) =>
+ OnToolSucceeded(state, ok, context),
+
+ ToolFailed failed when state.Phase is AwaitingTools && state.Pending is not null && state.Pending.RequestIds.Contains(failed.RequestId) =>
+ OnToolFailed(state, failed, context),
+
+ _ => new StepResult(state, []), // stale or duplicate — idempotent no-op
+ };
+ }
+
+ // ================================================================ question
+
+ private static StepResult OnQuestion(DerivationState state, QuestionReceived evt, StepContext context)
+ {
+ var envelope = QuestionEnvelope.FromJson(evt.QuestionJson);
+
+ var b = new Builder(state with
+ {
+ Question = envelope,
+ Sigma = envelope.InitialBindings,
+ });
+
+ // Pre-conditions gate the run: a violation is a USER error — fail, don't retry.
+ foreach (var clause in envelope.PreClauses)
+ {
+ var verdict = b.CheckContract(context, clause, isPre: true);
+
+ if (verdict is not null)
+ {
+ return verdict;
+ }
+ }
+
+ b.IssueLlmRequest(context, newChoicePoint: true);
+
+ return b.Freeze();
+ }
+
+ // ================================================================ LLM segments
+
+ private static StepResult OnLlmCompleted(DerivationState state, LlmCompleted evt, StepContext context)
+ {
+ var b = new Builder(state with { PendingRequestIds = [] });
+
+ b.Trace(seq => new SegmentReceived(seq, evt.Text, evt.StoppedAtHedge));
+
+ if (b.IsModeB)
+ {
+ return OnWholeProgram(b, state, evt, context);
+ }
+
+ var segments = HedgeScanner.Split(evt.Text);
+
+ // Truncated hedge: the model stopped on its own mid-hedge → one continuation retry.
+ if (!evt.StoppedAtHedge && segments.Length > 0 && segments[^1].IsOpenHedge)
+ {
+ if (b.Budget.ContinuationRetryUsed)
+ {
+ return b.Backtrack(context, "the generation stopped in the middle of a [ ... ] hedge twice", null);
+ }
+
+ b.Budget = b.Budget with { ContinuationRetryUsed = true };
+ b.AppendPrefill(evt.Text);
+ b.IssueLlmRequest(context, newChoicePoint: false); // same choice point: continuation, not an alternative
+ return b.Freeze();
+ }
+
+ var prose = "";
+
+ foreach (var segment in segments)
+ {
+ if (!segment.IsHedge)
+ {
+ prose += segment.Text;
+ continue;
+ }
+
+ // A closed hedge — or the final cut hedge (content complete; ']' is engine-owned).
+ var parsed = HedgeParser.Parse(segment.Text);
+
+ if (!parsed.Success)
+ {
+ if (!segment.Text.Contains('@', StringComparison.Ordinal) &&
+ !segment.Text.Contains('(', StringComparison.Ordinal))
+ {
+ prose += "[" + segment.Text + "]"; // degenerate hedge → prose
+ continue;
+ }
+
+ // A malformed hedge AFTER every declared output is bound is junk to skip, not
+ // a reason to unwind a finished derivation (observed live: @btcLeft bound and
+ // the branch taken, then [@totalCost (0.5)] — the backtrack destroyed the
+ // completed state and the request budget died before it could be rebuilt).
+ if (b.AnswerRequirementsMet)
+ {
+ b.Trace(seq => new ProtocolRepaired(seq, "junk-hedge-skipped", segment.Text));
+ continue;
+ }
+
+ return b.Backtrack(context, $"cannot parse [{segment.Text}]", parsed.Error);
+ }
+
+ if (parsed.Healed)
+ {
+ b.Trace(seq => new ProtocolRepaired(seq, "surplus-paren", segment.Text));
+ }
+
+ var outcome = b.ProcessRecognizedContent(context, prose, new HedgeItem(parsed.Statement!, segment.Text));
+ prose = "";
+
+ if (outcome is not null)
+ {
+ // A tool call started, or a failure backtracked — the segment's fate is decided.
+ // In either case the raw text (up to the cut) becomes part of the model trace.
+ return outcome;
+ }
+ }
+
+ if (evt.StoppedAtHedge)
+ {
+ // The hedge executed inline (binding / display / guard): the engine appends the
+ // closing bracket and resumes generation — the papers' interception protocol.
+ b.AppendPrefill(evt.Text + "]");
+ b.IssueLlmRequest(context, newChoicePoint: true);
+ return b.Freeze();
+ }
+
+ // Natural stop: trailing prose, no hedge → the derivation is complete.
+ var trailing = b.ProcessRecognizedContent(context, prose, hedge: null);
+ if (trailing is not null)
+ {
+ return trailing;
+ }
+
+ var finish = b.ProcessRecognizerFinish(context);
+ if (finish is not null)
+ {
+ return finish;
+ }
+
+ b.AppendPrefill(evt.Text);
+
+ return FinalizeDerivation(b, state, context);
+ }
+
+ ///
+ /// The completion gate shared by both modes: essay check, phantom-variable guard, the
+ /// declared-outputs completion contract, and post-conditions — then the answer.
+ ///
+ private static StepResult FinalizeDerivation(Builder b, DerivationState state, StepContext context)
+ {
+ // An answer with ZERO executed hedges is an essay, not a derivation (observed live:
+ // the model narrates what it WOULD do). Act, don't describe.
+ if (!b.HasExecutedAnything)
+ {
+ return b.Backtrack(context,
+ "the response contained no executable hedges — nothing was computed",
+ "act instead of describing: call the tools and bind values inside ⟨ ... ⟩ hedges, one per step");
+ }
+
+ // An answer claiming results in variables that were never computed is a hallucination
+ // (observed live: "the new paths are stored in @convertedDocs" with no such binding).
+ if (b.FindUnboundProseMention() is { } phantom)
+ {
+ return b.Backtrack(context,
+ $"the answer refers to '@{phantom}' but that variable was never computed",
+ $"compute '@{phantom}' with a real ⟨ ... ⟩ hedge before mentioning it — results only exist when a hedge produced them");
+ }
+
+ // Declared outputs are a completion CONTRACT: finishing without computing one is an
+ // incomplete answer, not a style choice (observed live: the model narrated success in
+ // prose while the required variable was never bound — and the quotation-aware phantom
+ // guard rightly forgave the narration, so nothing else would have caught it).
+ if (state.Question is not null &&
+ state.Question.ExpectedOutputs.FirstOrDefault(name => !b.HasTopLevelBinding(name)) is { } missing)
+ {
+ // Observed live with checklists: the model DISPLAYS the right value in a branch but
+ // never binds it — the binding belongs inside every branch (cf. the ticket few-shot).
+ var hint = b.HasConditional
+ ? $"every '- If …, then …' and '- Otherwise …' branch must bind it: end the branch with ⟨@{missing} is …⟩ or ⟨@{missing} = …⟩"
+ : $"bind '@{missing}' before finishing — a tool or rule call with '@{missing}' as the final output argument, or a calculation '@{missing} is …'";
+
+ return b.Backtrack(context, $"the question requires '@{missing}' but it was never computed", hint);
+ }
+
+ // Post-conditions check the COMPLETED derivation; a violation backtracks (the model's
+ // plan produced values that break the contract — a different derivation might not).
+ if (state.Question is not null)
+ {
+ foreach (var clause in state.Question.PostClauses)
+ {
+ var verdict = b.CheckContract(context, clause, isPre: false);
+
+ if (verdict is not null)
+ {
+ return verdict;
+ }
+ }
+ }
+
+ return b.Complete();
+ }
+
+ // ================================================================ Mode B: whole-program synthesis
+
+ ///
+ /// Mode B: the entire program arrives as ONE structured completion in the papers'
+ /// {comment|expression}[] interchange form. Import re-parses every expression through the
+ /// same hedge grammar, the walk feeds (prose, hedge) pairs through the same literate
+ /// recognizer — so conditionals, queries, tools, rules, teachings, and contracts behave
+ /// exactly as in Mode A. Only the backtracking is coarse: ANY failure regenerates the whole
+ /// program with the failure carried as feedback. This is the conformance floor for models
+ /// that cannot hold the hedge-by-hedge interception protocol.
+ ///
+ private static StepResult OnWholeProgram(Builder b, DerivationState state, LlmCompleted evt, StepContext context)
+ {
+ var import = PaperShape.Import(ExtractProgramJson(evt.Text));
+
+ if (import.Program is null)
+ {
+ // Truncation is not malformation: the model already returned valid JSON and was cut
+ // at the token cap — telling it "return valid JSON" teaches the wrong lesson while
+ // the next attempt runs into the same ceiling (the cap grows with the attempt
+ // number in BuildWholeProgram; the teach asks for brevity as well).
+ if (evt.Truncated)
+ {
+ return b.Backtrack(context,
+ "the program was cut off at the generation limit before it ended",
+ "write a SHORTER program: fewer and terser comments, no repetition — only the steps the answer needs");
+ }
+
+ return b.Backtrack(context,
+ $"the program was rejected: {import.Error}",
+ "return ONLY the JSON object {\"program\": [ … ]} whose items are {\"comment\": \"…\"} or {\"expression\": \"…\"} — the COMPLETE program as valid JSON, nothing else");
+ }
+
+ foreach (var warning in import.Warnings)
+ {
+ b.Trace(seq => new ProtocolRepaired(seq, "import-healed", warning));
+ }
+
+ b.StartModeBWalk(import.Program.Items);
+
+ var walk = b.ContinueModeBWalk(context);
+ return walk ?? FinalizeDerivation(b, state, context);
+ }
+
+ /// Unwraps the schema root object and stray markdown fences down to the bare array.
+ private static string ExtractProgramJson(string text)
+ {
+ var trimmed = text.Trim();
+
+ if (trimmed.StartsWith("```", StringComparison.Ordinal))
+ {
+ var firstLineEnd = trimmed.IndexOf('\n');
+ var lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
+
+ if (firstLineEnd >= 0 && lastFence > firstLineEnd)
+ {
+ trimmed = trimmed[(firstLineEnd + 1)..lastFence].Trim();
+ }
+ }
+
+ if (trimmed.StartsWith('{'))
+ {
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(trimmed);
+
+ if (doc.RootElement.TryGetProperty("program", out var program))
+ {
+ return program.GetRawText();
+ }
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ // Fall through — Import produces the teaching error.
+ }
+ }
+
+ return trimmed;
+ }
+
+ // ================================================================ tools
+
+ private static StepResult OnToolSucceeded(DerivationState state, ToolSucceeded evt, StepContext context)
+ {
+ var pending = state.Pending!;
+ var index = pending.RequestIds.IndexOf(evt.RequestId);
+
+ if (pending.Results[index] is not null)
+ {
+ return new StepResult(state, []); // duplicate delivery
+ }
+
+ var b = new Builder(state);
+
+ if (evt.ResultsJson.Length == 0)
+ {
+ return b.Backtrack(context, $"'{pending.Signature.Name}' returned no results", null);
+ }
+
+ // Tools are relations (0..n results); fixed-mode execution takes the first alternative.
+ var updated = pending with { Results = pending.Results.SetItem(index, evt.ResultsJson[0]) };
+ b.Pending = updated;
+
+ if (!updated.AllCollected)
+ {
+ // Keep PendingRequestIds precise: only the still-outstanding invocations of the
+ // lifted batch. Recovery re-issues exactly these (a kill between partial results
+ // must not orphan the rest of the batch — observed live as a wedged resume).
+ b.SetPendingRequestIds([.. updated.RequestIds.Where((_, i) => updated.Results[i] is null)]);
+ return b.Freeze();
+ }
+
+ var env = b.Env();
+ var outcome = Evaluator.BindToolResults(
+ updated.Signature,
+ updated.OutArgs,
+ [.. updated.Results.Select(r => r!)],
+ env);
+
+ if (outcome is EvalFailure failure)
+ {
+ return b.Backtrack(context, failure.Message, failure.Hint);
+ }
+
+ foreach (var binding in ((Bound)outcome).Bindings)
+ {
+ b.Bind(binding, source: updated.Signature.Name);
+ }
+
+ b.Pending = null;
+
+ // A tool that completed INSIDE a rule resumes the rule interpreter, not the LLM.
+ if (b.HasFrames)
+ {
+ var resumed = b.RunFrames(context);
+
+ if (resumed is not null)
+ {
+ return resumed;
+ }
+ }
+
+ // Mode B: the program is already fully known — resume the imported walk instead of
+ // asking the model for the next segment.
+ if (b.IsModeB)
+ {
+ var walk = b.ContinueModeBWalk(context);
+ return walk ?? FinalizeDerivation(b, state, context);
+ }
+
+ // Close the bracket the model left open and resume generation.
+ b.AppendPrefill("]");
+ b.IssueLlmRequest(context, newChoicePoint: true);
+
+ return b.Freeze();
+ }
+
+ private static StepResult OnToolFailed(DerivationState state, ToolFailed evt, StepContext context)
+ {
+ var b = new Builder(state);
+ return b.Backtrack(context, $"'{state.Pending!.Signature.Name}' failed: {evt.Error}", "try different arguments or a different tool");
+ }
+
+ // ================================================================ builder
+
+ /// Mutable working copy of the state during one step; frozen into the result.
+ private sealed class Builder
+ {
+ private readonly string _conversationId;
+ private long _seq;
+ private Phase _phase;
+ private ImmutableArray _pendingRequestIds;
+ private readonly QuestionEnvelope? _question;
+ private ImmutableArray.Builder _program;
+ private ImmutableDictionary _sigma;
+ private ImmutableArray _choicePoints;
+ private LiterateRecognizer _recognizer;
+ private string _prefill;
+ private ImmutableArray _engineNotes;
+ private int _llmRequestCount;
+ private ImmutableArray _frames;
+ private ImmutableArray _modeBQueue;
+ private int _modeBPc;
+ private readonly ImmutableArray.Builder _effects = ImmutableArray.CreateBuilder();
+
+ public RetryBudget Budget { get; set; }
+
+ public PendingCall? Pending { get; set; }
+
+ public Builder(DerivationState state)
+ {
+ _conversationId = state.ConversationId;
+ _seq = state.NextSeq;
+ _phase = state.Phase;
+ _pendingRequestIds = state.PendingRequestIds;
+ _question = state.Question;
+ _program = state.ProgramSoFar.ToBuilder();
+ _sigma = state.Sigma;
+ _choicePoints = state.ChoicePoints;
+ Budget = state.Budget;
+ _recognizer = new LiterateRecognizer(state.Recognizer);
+ _prefill = state.AssistantPrefill;
+ _engineNotes = state.EngineNotes;
+ Pending = state.Pending;
+ _llmRequestCount = state.LlmRequestCount;
+ _frames = state.Frames;
+ _modeBQueue = state.ModeBQueue.IsDefault ? [] : state.ModeBQueue;
+ _modeBPc = state.ModeBPc;
+ }
+
+ public bool HasFrames => _frames.Length > 0;
+
+ public bool IsModeB => _question?.ModeB == true;
+
+ /// True when the derivation ever executed a hedge or block (vs pure prose).
+ public bool HasExecutedAnything => _program.Any(i => i is HedgeItem or ConditionalBlock or ComprehensionBlock);
+
+ /// First @variable mentioned in answer prose that σ never bound, if any.
+ public string? FindUnboundProseMention()
+ {
+ // Variables the ENGINE itself introduced in steering (teachings quote shapes like
+ // ⟨TOOL(@in, @newVar)⟩): a model repeating them in prose is quoting the teacher,
+ // not claiming a computed value. Vetoing quotations threw away three correct
+ // answers live ('@in' parroted from an affordance example).
+ var quoted = new HashSet(StringComparer.Ordinal);
+
+ foreach (var item in _program)
+ {
+ if (item is Comment { Aside: true } aside)
+ {
+ foreach (System.Text.RegularExpressions.Match match in
+ System.Text.RegularExpressions.Regex.Matches(aside.Text, "@([A-Za-z_][A-Za-z0-9_]*)"))
+ {
+ quoted.Add(match.Groups[1].Value);
+ }
+ }
+ }
+
+ foreach (var item in _program)
+ {
+ if (item is not Comment { Aside: false } comment)
+ {
+ continue;
+ }
+
+ foreach (System.Text.RegularExpressions.Match match in
+ System.Text.RegularExpressions.Regex.Matches(comment.Text, "@([A-Za-z_][A-Za-z0-9_]*)"))
+ {
+ var name = match.Groups[1].Value;
+
+ if (!_sigma.ContainsKey(name) && !quoted.Contains(name))
+ {
+ return name;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public EvalEnv Env()
+ {
+ // A rule body is a Tennent abstraction: it sees ONLY its own frame's locals
+ // (in-params seeded at entry, plus its body bindings) — never the caller's σ.
+ // Layering the caller underneath was dynamic scoping: a body-local @lat collided
+ // with a caller @lat bound by an earlier direct tool call (observed live), and
+ // since rule execution is deterministic, no retry could ever succeed.
+ return _frames.Length > 0
+ ? EvalEnv.FromSigma(_frames[^1].Locals)
+ : EvalEnv.FromSigma(_sigma);
+ }
+
+ public void Trace(Func make) => _effects.Add(new EmitTrace(make(_seq++).ToJson()));
+
+ public void Bind(Binding binding, string source)
+ {
+ if (_frames.Length > 0)
+ {
+ var top = _frames[^1];
+ _frames = _frames.SetItem(_frames.Length - 1,
+ top with { Locals = top.Locals.SetItem(binding.Var, binding.Json) });
+ }
+ else
+ {
+ _sigma = _sigma.SetItem(binding.Var, binding.Json);
+ }
+
+ Trace(seq => new VariableBound(seq, binding.Var, binding.Json, source));
+ }
+
+ public bool HasTopLevelBinding(string name) => _sigma.ContainsKey(name);
+
+ public bool HasConditional => _program.Any(item => item is ConditionalBlock);
+
+ /// Every declared output is bound — the answer exists, whatever else happens.
+ public bool AnswerRequirementsMet =>
+ _question is not null &&
+ !_question.ExpectedOutputs.IsEmpty &&
+ _question.ExpectedOutputs.All(_sigma.ContainsKey);
+
+ public void AppendPrefill(string text) => _prefill += text;
+
+ public void SetPendingRequestIds(ImmutableArray requestIds) => _pendingRequestIds = requestIds;
+
+ private string NextRequestId() => $"{_conversationId}/{_seq++}";
+
+ // ---------------------------------------------------------- LLM issuance
+
+ public void IssueLlmRequest(StepContext context, bool newChoicePoint)
+ {
+ if (IsModeB)
+ {
+ IssueWholeProgramRequest(context);
+ return;
+ }
+
+ // The budget must bind on the SUCCESS paths too (hedge-cut resume, tool-completion
+ // resume, continuation retry) — enforcing it only in the backtracking machinery left
+ // a never-failing narration loop unbounded (review finding).
+ if (_llmRequestCount >= Budget.MaxLlmRequests)
+ {
+ FailInPlace("LLM request budget exhausted");
+ return;
+ }
+
+ _llmRequestCount++;
+
+ if (newChoicePoint)
+ {
+ _choicePoints = _choicePoints.Add(new ChoicePoint(
+ _program.Count, _sigma, _prefill, _recognizer.State, Attempt: 0));
+ }
+
+ var attempt = _choicePoints.Length > 0 ? _choicePoints[^1].Attempt : 0;
+ var requestId = NextRequestId();
+
+ var prompt = PromptAssembler.Build(
+ _question!, context, _prefill, _engineNotes, attempt, _conversationId, requestId);
+
+ _effects.Add(new RequestLlm(requestId, prompt.ToJson()));
+ _phase = new Synthesizing();
+ _pendingRequestIds = [requestId];
+ }
+
+ /// Mode B: one request returns the WHOLE program — no choice points, no prefill.
+ private void IssueWholeProgramRequest(StepContext context)
+ {
+ if (_llmRequestCount >= Budget.MaxLlmRequests)
+ {
+ FailInPlace("LLM request budget exhausted");
+ return;
+ }
+
+ _llmRequestCount++;
+
+ var requestId = NextRequestId();
+ var prompt = PromptAssembler.BuildWholeProgram(
+ _question!, context, _engineNotes, attempt: _llmRequestCount - 1, _conversationId, requestId);
+
+ _effects.Add(new RequestLlm(requestId, prompt.ToJson()));
+ _phase = new Synthesizing();
+ _pendingRequestIds = [requestId];
+ }
+
+ // ---------------------------------------------------------- Mode B walk
+
+ public void StartModeBWalk(ImmutableArray items)
+ {
+ _modeBQueue = items;
+ _modeBPc = 0;
+ }
+
+ ///
+ /// Executes imported items until a tool suspends the walk, a failure regenerates, or
+ /// the program ends (null = walk finished; the caller runs the completion gate).
+ /// Consecutive comments join with newlines — the interchange form is line-granular and
+ /// the recognizer keys bullets ('- If …', '- Retain only …') on line starts, so the
+ /// joined prose reconstructs blocks exactly as Mode A streaming would.
+ ///
+ public StepResult? ContinueModeBWalk(StepContext context)
+ {
+ while (_modeBPc < _modeBQueue.Length)
+ {
+ var prose = "";
+
+ while (_modeBPc < _modeBQueue.Length && _modeBQueue[_modeBPc] is Comment comment)
+ {
+ prose += (prose.Length > 0 ? "\n" : "") + comment.Text;
+ _modeBPc++;
+ }
+
+ HedgeItem? hedge = null;
+
+ if (_modeBPc < _modeBQueue.Length && _modeBQueue[_modeBPc] is HedgeItem next)
+ {
+ hedge = next;
+ _modeBPc++;
+ }
+
+ var outcome = ProcessRecognizedContent(context, prose, hedge);
+
+ if (outcome is not null)
+ {
+ return outcome;
+ }
+ }
+
+ return ProcessRecognizerFinish(context);
+ }
+
+ // ---------------------------------------------------------- content processing
+
+ ///
+ /// Routes (prose, hedge) through the literate recognizer and executes what it emits.
+ /// Returns a final StepResult when the step's fate is decided (tool call issued,
+ /// backtracked, failed); null to continue processing.
+ ///
+ public StepResult? ProcessRecognizedContent(StepContext context, string prose, HedgeItem? hedge)
+ {
+ foreach (var evt in _recognizer.Advance(prose, hedge))
+ {
+ var result = ProcessRecognizerEvent(context, evt);
+ if (result is not null)
+ {
+ return result;
+ }
+ }
+
+ return null;
+ }
+
+ public StepResult? ProcessRecognizerFinish(StepContext context)
+ {
+ foreach (var evt in _recognizer.Finish())
+ {
+ var result = ProcessRecognizerEvent(context, evt);
+ if (result is not null)
+ {
+ return result;
+ }
+ }
+
+ return null;
+ }
+
+ private StepResult? ProcessRecognizerEvent(StepContext context, RecognizerEvent evt)
+ {
+ switch (evt)
+ {
+ case ProseEmitted prose:
+ // Models sometimes echo the engine-appended ']' at the start of their next
+ // prose run — protocol debris, never content (observed live as answer text
+ // like "using ]. The engine…"). The prefill keeps the model's own tokens;
+ // only the assembled-answer view is cleaned.
+ _program.Add(new Comment(
+ System.Text.RegularExpressions.Regex.Replace(prose.Text, @"^\s*\]\.?\s*", " ")));
+ return null;
+
+ case ExecuteHedge hedge:
+ return ExecuteHedgeNow(context, hedge.Hedge, repaired: false);
+
+ case ConditionalCompleted conditional:
+ return ExecuteConditional(context, conditional.Block);
+
+ case ComprehensionCompleted comprehension:
+ return ExecuteComprehension(context, comprehension.Draft);
+
+ default:
+ return null;
+ }
+ }
+
+ private StepResult? ExecuteHedgeNow(StepContext context, HedgeItem hedge, bool repaired)
+ {
+ // A σ-mutating hedge inside an "If …" SENTENCE (not a checklist bullet) executes
+ // unconditionally — the engine cannot evaluate a prose condition. Observed live:
+ // "If Sam has enough money, he … [$@remaining is @price - @cash]. If not, he keeps
+ // [$@remaining is @cash]" bound the then-arm's value with the guard FALSE, and the
+ // poisoned σ made every honest checklist retry fail its assertion to depth death.
+ if (!repaired && hedge.Statement is not DisplayStmt && InConditionalSentence())
+ {
+ return Backtrack(context,
+ $"[{hedge.ConcreteText}] is guarded by an 'If …' sentence the engine cannot evaluate",
+ "prose never guards a computation — write the decision as checklist bullets " +
+ "'- If ⟨condition⟩, then …' and '- Otherwise, …', binding each branch's value inside its own bullet");
+ }
+
+ var outcome = Evaluator.Evaluate(hedge.Statement, Env(), context.BuildCatalog());
+
+ switch (outcome)
+ {
+ case Bound bound:
+ _program.Add(hedge);
+ foreach (var binding in bound.Bindings)
+ {
+ Bind(binding, SourceOf(hedge.Statement));
+ }
+
+ return null;
+
+ case DisplayValue display:
+ _program.Add(hedge);
+ Trace(seq => new DisplayShown(seq, hedge.ConcreteText, display.Formatted));
+ return null;
+
+ case GuardResult guard:
+ _program.Add(hedge);
+ Trace(seq => new GuardEvaluated(seq, -1, hedge.ConcreteText, guard.Value));
+ return guard.Value
+ ? null
+ : Backtrack(context, $"the assertion [{hedge.ConcreteText}] is false",
+ "both sides are already bound to different values — earlier prose computed one of them; either drop the hedge (say it in plain prose) or bind a FRESH variable if you meant a new value");
+
+ case NeedTool need:
+ return StartToolCall(context, hedge, need);
+
+ case NeedRule need:
+ return InvokeRule(context, hedge, need);
+
+ case EvalFailure { Code: EvalFailureCodes.LiteralInOutPosition } when !repaired:
+ return AutoRepairOutArgs(context, hedge);
+
+ case EvalFailure { Code: EvalFailureCodes.Rebind } when !repaired && hedge.Statement is PredicateCall:
+ return HandleRepeatedCall(context, hedge);
+
+ // [@x is TOOL(@a, @b)] where the call was already complete: the is-rewrite
+ // appended @x as an extra arg. Drop it and run the call as written.
+ // An invented extraction predicate over a bound object and a pattern IS a
+ // pattern match — the intent is unambiguous (observed live: [EXTRACT(@stockData,
+ // { "close": @price })] dead-ending as unknown). Run the match the model meant,
+ // opened, since extraction never implies an exact key set.
+ case EvalFailure { Code: EvalFailureCodes.UnknownPredicate } when !repaired &&
+ hedge.Statement is PredicateCall { Args: [VarTerm source, ObjectPatternTerm extractPattern] } &&
+ Env().IsBound(source.Name):
+ {
+ Trace(seq => new ProtocolRepaired(seq, "extract-as-match", hedge.ConcreteText));
+ var match = new BindStmt(new VarTerm(source.Name), extractPattern with { IsOpen = true });
+
+ return ExecuteHedgeNow(context,
+ new HedgeItem(match, ConcreteRenderer.RenderStatement(match, RenderMode.Formulas)),
+ repaired: true);
+ }
+
+ case EvalFailure { Code: EvalFailureCodes.Arity } when !repaired &&
+ hedge.Statement is PredicateCall { Args: [.., var beforeExtra, VarTerm extra] } overfull &&
+ context.BuildCatalog().TryGet(overfull.Name, out var sig) &&
+ overfull.Args.Length == sig.Params.Length + 1 &&
+ // A BOUND extra is still trimmable when a pattern carries the real
+ // out-binding: [@p is STOCK("MSFT", { "close": @close })] with @p already
+ // bound (observed live — the guard used to refuse and the roll died).
+ (!Env().IsBound(extra.Name) || beforeExtra is ObjectPatternTerm):
+ {
+ var trimmed = overfull with { Args = overfull.Args.RemoveAt(overfull.Args.Length - 1) };
+ Trace(seq => new ProtocolRepaired(seq, "extra-out-arg", hedge.ConcreteText));
+ return ExecuteHedgeNow(context,
+ new HedgeItem(trimmed, ConcreteRenderer.RenderStatement(trimmed, RenderMode.Formulas)),
+ repaired: true);
+ }
+
+ // Once every declared output is bound the answer EXISTS — a failing trailing
+ // hedge is post-answer noise to skip, never a reason to unwind the finished
+ // state (observed live twice in one roll: @btcLeft bound, branch taken, then a
+ // junk MATH re-call backtracked the completed derivation into budget death).
+ case EvalFailure when AnswerRequirementsMet:
+ Trace(seq => new ProtocolRepaired(seq, "post-answer-noise", hedge.ConcreteText));
+ return null;
+
+ case EvalFailure { Code: EvalFailureCodes.Unbound } failure:
+ return Backtrack(context, $"[{hedge.ConcreteText}] failed: {failure.Message}", UnboundHint());
+
+ case EvalFailure failure:
+ return Backtrack(context, $"[{hedge.ConcreteText}] failed: {failure.Message}", failure.Hint);
+
+ default:
+ return Backtrack(context, $"unexpected evaluation outcome {outcome.GetType().Name}", null);
+ }
+ }
+
+ ///
+ /// The repeat-after-success pathology (observed live): after a successful call and resume,
+ /// small models re-narrate the same call with the already-bound output variable. If every
+ /// output is already bound, the narration is redundant — the call HAPPENED; close the
+ /// bracket and move on (never re-execute: the tool may not be idempotent). If only some
+ /// outputs are rebound, substitute fresh variables for those and execute.
+ ///
+ private StepResult? HandleRepeatedCall(StepContext context, HedgeItem hedge)
+ {
+ var call = (PredicateCall)hedge.Statement;
+
+ if (!context.BuildCatalog().TryGet(call.Name, out var signature) ||
+ !Evaluator.TryNormalizeNamedArgs(call, signature, out call) ||
+ call.Args.Length != signature.Params.Length)
+ {
+ return Backtrack(context, $"[{hedge.ConcreteText}] reuses a bound variable", "write a fresh variable name for every tool output");
+ }
+
+ var env = Env();
+ var outArgs = signature.Params
+ .Select((p, i) => (Param: p, Arg: call.Args[i]))
+ .Where(x => x.Param.Mode == ParamMode.Out)
+ .ToList();
+
+ var allOutsBound = outArgs.All(x => x.Arg is VarTerm v && env.IsBound(v.Name));
+
+ // Narration ⇔ the same call (by IN-argument VALUES) already ran. Different inputs
+ // with a reused output variable is a genuinely new call — repair, don't skip.
+ var currentKey = InArgsKey(call, signature, env);
+ var isNarration = allOutsBound && currentKey is not null && _program
+ .OfType()
+ .Select(h => h.Statement)
+ .OfType()
+ .Any(prior =>
+ string.Equals(prior.Name, call.Name, StringComparison.OrdinalIgnoreCase) &&
+ InArgsKey(prior, signature, env) == currentKey);
+
+ if (isNarration)
+ {
+ Trace(seq => new ProtocolRepaired(seq, "duplicate-call", hedge.ConcreteText));
+
+ // The prose leading INTO redundant re-narration is itself re-narration — keep
+ // it out of the assembled answer (observed live: "First, list the files…" ×3).
+ if (_program.Count > 0 && _program[^1] is Comment { Aside: false } lead)
+ {
+ _program[_program.Count - 1] = lead with { Aside = true };
+ }
+
+ return null; // redundant narration — resume generation, no re-execution
+ }
+
+ var args = call.Args.ToBuilder();
+
+ for (var i = 0; i < signature.Params.Length; i++)
+ {
+ if (signature.Params[i].Mode == ParamMode.Out && args[i] is VarTerm v && env.IsBound(v.Name))
+ {
+ args[i] = new VarTerm($"auto{_seq++}");
+ }
+ }
+
+ var repairedCall = call with { Args = args.ToImmutable() };
+ var repairedHedge = new HedgeItem(repairedCall, ConcreteRenderer.RenderStatement(repairedCall, RenderMode.Formulas));
+
+ Trace(seq => new ProtocolRepaired(seq, "rebound-output", hedge.ConcreteText));
+
+ return ExecuteHedgeNow(context, repairedHedge, repaired: true);
+ }
+
+ ///
+ /// Context-aware teaching for unbound-variable failures: what IS bound, and — when a
+ /// collection is bound — the comprehension form to process it with. (Observed live:
+ /// without this the model free-forms counting logic and dies referencing phantom vars.)
+ ///
+ private string UnboundHint()
+ {
+ // No call-shaped placeholders here: models INVOKE them literally (observed live:
+ // 'unknown predicate TOOL' burned whole budgets). Wordy descriptions are imitated
+ // correctly; example calls are imitated verbatim.
+ var bound = _sigma.Keys.OrderBy(k => k, StringComparer.Ordinal).Take(8).ToList();
+ var hint = bound.Count == 0
+ ? "no variables are bound yet; compute values first with ⟨@newVar is …⟩ or by calling one of the TOOLS"
+ : $"bound so far: {string.Join(", ", bound.Select(n => "@" + n))}; a new variable is bound ONLY by a computation that outputs it — call one of the TOOLS with the new variable as its last argument, or compute ⟨@newVar is …⟩";
+
+ var collection = bound.FirstOrDefault(n => _sigma[n].TrimStart().StartsWith('['));
+
+ if (collection is not null)
+ {
+ hint += $". To count or filter @{collection}, write exactly: Consider each ⟨@item = {{ … \"field\": @field … }}⟩ from ⟨@{collection}⟩: then bullets '- Retain only … ⟨condition⟩.' and '- Subsequently, increment ⟨@total⟩ by one …'";
+ }
+
+ return hint;
+ }
+
+ /// In-argument identity by VALUE (bound variables substituted), for duplicate detection.
+ private static string? InArgsKey(PredicateCall call, PredicateSignature signature, EvalEnv env)
+ {
+ if (call.Args.Length != signature.Params.Length)
+ {
+ return null;
+ }
+
+ var parts = new List();
+
+ for (var i = 0; i < signature.Params.Length; i++)
+ {
+ if (signature.Params[i].Mode != ParamMode.In)
+ {
+ continue;
+ }
+
+ if (!Evaluator.IsGround(call.Args[i], env))
+ {
+ return null;
+ }
+
+ parts.Add(ConcreteRenderer.RenderTerm(call.Args[i], RenderMode.Values, env));
+ }
+
+ // A visible escape, deliberately: this used to be a literal U+0001 BETWEEN the quotes, // which rendered as Join("", parts) to every reader and got code-reviewed as a // key-collision bug. The separator is load-bearing: without one, MATH(12, 3) and // MATH(1, 23) would share an identity and a genuinely new call would be skipped as // duplicate narration, silently keeping a stale output.
+ return string.Join("\u001F", parts);
+ }
+
+ ///
+ /// Paper 1's discard-and-replace rule: the model hallucinated a value where an output
+ /// belongs; substitute a fresh variable and proceed — the engine's value wins anyway.
+ ///
+ private StepResult? AutoRepairOutArgs(StepContext context, HedgeItem hedge)
+ {
+ if (hedge.Statement is not PredicateCall call ||
+ !context.BuildCatalog().TryGet(call.Name, out var signature) ||
+ !Evaluator.TryNormalizeNamedArgs(call, signature, out call))
+ {
+ return Backtrack(context, $"[{hedge.ConcreteText}] misuses an output position", null);
+ }
+
+ var args = call.Args.ToBuilder();
+
+ for (var i = 0; i < signature.Params.Length && i < args.Count; i++)
+ {
+ if (signature.Params[i].Mode == ParamMode.Out && args[i] is not VarTerm and not ObjectPatternTerm and not ArrayPatternTerm)
+ {
+ args[i] = new VarTerm($"auto{_seq++}");
+ }
+ }
+
+ var repairedCall = call with { Args = args.ToImmutable() };
+ var repairedHedge = new HedgeItem(repairedCall, ConcreteRenderer.RenderStatement(repairedCall, RenderMode.Formulas));
+
+ Trace(seq => new ProtocolRepaired(seq, "literal-in-out-position", hedge.ConcreteText));
+
+ return ExecuteHedgeNow(context, repairedHedge, repaired: true);
+ }
+
+ // ---------------------------------------------------------- stored rules (frames)
+
+ /// Activates a stored rule: push a frame and interpret its body — zero LLM calls inside.
+ private StepResult? InvokeRule(StepContext context, HedgeItem? hedge, NeedRule need)
+ {
+ // Last-wins, matching SignatureCatalog.BuildCatalog and the host's "seeds come last
+ // so they shadow" contract — FirstOrDefault executed a stale stored body against the
+ // shadowing seed's signature (review finding).
+ var rule = context.Rules.LastOrDefault(r =>
+ string.Equals(r.Signature.Name, need.Signature.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (rule is null)
+ {
+ return Backtrack(context, $"rule '{need.Signature.Name}' has no stored definition", null);
+ }
+
+ if (_frames.Length >= 8)
+ {
+ return Backtrack(context, "rule recursion is too deep", null);
+ }
+
+ if (hedge is not null)
+ {
+ _program.Add(hedge);
+ }
+
+ Trace(seq => new RuleInvoked(seq, rule.Signature.Name));
+
+ _frames = _frames.Add(new RuleFrame(
+ rule.Signature.Name,
+ rule.Body.Items,
+ Pc: 0,
+ Locals: need.InBindings.ToImmutableDictionary(b => b.Var, b => b.Json),
+ need.OutArgs,
+ [.. rule.Signature.Params.Where(p => p.Mode == ParamMode.Out).Select(p => p.Name)]));
+
+ return RunFrames(context);
+ }
+
+ ///
+ /// Interprets active rule frames until they all complete (null) or the machine suspends
+ /// on a tool / backtracks / fails (a StepResult). Resumable: Pc and Locals are part of
+ /// the checkpointed state, so a kill mid-rule recovers mid-rule.
+ ///
+ public StepResult? RunFrames(StepContext context)
+ {
+ while (_frames.Length > 0)
+ {
+ var frame = _frames[^1];
+
+ if (frame.Pc >= frame.Body.Length)
+ {
+ var popped = PopFrame(context, frame);
+
+ if (popped is not null)
+ {
+ return popped;
+ }
+
+ continue;
+ }
+
+ var item = frame.Body[frame.Pc];
+ _frames = _frames.SetItem(_frames.Length - 1, frame with { Pc = frame.Pc + 1 });
+
+ switch (item)
+ {
+ case Comment:
+ continue;
+
+ case HedgeItem hedgeItem:
+ {
+ var result = ExecuteRuleStatement(context, frame.RuleName, hedgeItem);
+
+ if (result is not null)
+ {
+ return result;
+ }
+
+ continue;
+ }
+
+ case ConditionalBlock conditional:
+ {
+ var result = ExecuteConditional(context, conditional, addToProgram: false);
+
+ if (result is not null)
+ {
+ return result;
+ }
+
+ continue;
+ }
+
+ case ComprehensionBlock comprehension:
+ {
+ var outcome = QueryPipeline.Execute(comprehension, Env(), out _, out _);
+
+ if (outcome is EvalFailure failure)
+ {
+ return Backtrack(context, $"inside rule '{frame.RuleName}': the query failed: {failure.Message}", failure.Hint);
+ }
+
+ foreach (var binding in ((Bound)outcome).Bindings)
+ {
+ Bind(binding, source: "query");
+ }
+
+ continue;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private StepResult? ExecuteRuleStatement(StepContext context, string ruleName, HedgeItem hedge)
+ {
+ var outcome = Evaluator.Evaluate(hedge.Statement, Env(), context.BuildCatalog());
+
+ switch (outcome)
+ {
+ case Bound bound:
+ foreach (var binding in bound.Bindings)
+ {
+ Bind(binding, SourceOf(hedge.Statement));
+ }
+
+ return null;
+
+ case DisplayValue display:
+ Trace(seq => new DisplayShown(seq, hedge.ConcreteText, display.Formatted));
+ return null;
+
+ case GuardResult { Value: true }:
+ return null;
+
+ case GuardResult:
+ return Backtrack(context, $"inside rule '{ruleName}': the assertion [{hedge.ConcreteText}] is false", null);
+
+ case NeedTool need:
+ return StartToolCall(context, hedge: null, need);
+
+ case NeedRule nested:
+ return InvokeRule(context, hedge: null, nested);
+
+ case EvalFailure failure:
+ return Backtrack(context, $"inside rule '{ruleName}': [{hedge.ConcreteText}] failed: {failure.Message}", failure.Hint);
+
+ default:
+ return Backtrack(context, $"inside rule '{ruleName}': unexpected outcome {outcome.GetType().Name}", null);
+ }
+ }
+
+ /// Copies the rule's formal outputs back to the caller's out-terms, then pops.
+ private StepResult? PopFrame(StepContext context, RuleFrame frame)
+ {
+ _frames = _frames.RemoveAt(_frames.Length - 1);
+
+ var outerEnv = Env();
+
+ for (var i = 0; i < frame.FormalOuts.Length && i < frame.CallerOutArgs.Length; i++)
+ {
+ if (!frame.Locals.TryGetValue(frame.FormalOuts[i], out var value))
+ {
+ return Backtrack(context,
+ $"rule '{frame.RuleName}' completed without binding its output '@{frame.FormalOuts[i]}'", null);
+ }
+
+ switch (frame.CallerOutArgs[i])
+ {
+ case VarTerm v:
+ Bind(new Binding(v.Name, value), source: frame.RuleName);
+ break;
+
+ default:
+ {
+ var match = PatternMatcher.Match(
+ frame.CallerOutArgs[i],
+ System.Text.Json.Nodes.JsonNode.Parse(value),
+ outerEnv);
+
+ if (!match.Success)
+ {
+ // Observed live: a destructuring pattern in a rule's output
+ // position when the rule returns a list. Teach the plain form.
+ return Backtrack(context,
+ $"the output pattern for rule '{frame.RuleName}' did not match: {match.Reason}",
+ "bind the rule's output to ONE plain fresh variable — no pattern — then display or query it");
+ }
+
+ foreach (var binding in match.Bindings)
+ {
+ Bind(binding, source: frame.RuleName);
+ }
+
+ break;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private StepResult StartToolCall(StepContext context, HedgeItem? hedge, NeedTool need)
+ {
+ var binding = context.FindTool(need.Signature.Name);
+
+ if (binding is null)
+ {
+ return Backtrack(context, $"tool '{need.Signature.Name}' has no engine binding", null)!;
+ }
+
+ if (hedge is not null)
+ {
+ _program.Add(hedge);
+ }
+
+ var requestIds = ImmutableArray.CreateBuilder(need.Plan.Count);
+
+ for (var k = 0; k < need.Plan.Count; k++)
+ {
+ requestIds.Add(NextRequestId());
+ }
+
+ Pending = new PendingCall(
+ need.Signature,
+ binding.ToolUri,
+ binding.IsIdempotent,
+ need.OutArgs,
+ requestIds.ToImmutable(),
+ [.. Enumerable.Repeat(null, need.Plan.Count)]);
+
+ Trace(seq => new ToolInvoked(seq, need.Signature.Name, need.Plan.InvocationArgsJson[0], need.Plan.Count));
+
+ for (var k = 0; k < need.Plan.Count; k++)
+ {
+ _effects.Add(new InvokeTool(Pending.RequestIds[k], binding.ToolUri, need.Plan.InvocationArgsJson[k]));
+ }
+
+ _phase = new AwaitingTools();
+ _pendingRequestIds = Pending.RequestIds;
+
+ return Freeze();
+ }
+
+ /// Query comprehension: compile the recognized draft, run the LINQ pipeline, bind results.
+ private StepResult? ExecuteComprehension(StepContext context, ComprehensionDraft draft)
+ {
+ var (block, error) = ComprehensionCompiler.Compile(draft);
+
+ if (block is null)
+ {
+ // The rewind erases the WHOLE block including its opener, so the steering must ask
+ // for the whole query back — a model told to fix one bullet re-emits just that
+ // bullet, which then executes as a top-level hedge against an empty row scope.
+ return Backtrack(context, $"could not compile the query: {error}",
+ "rewrite the WHOLE query from its first line — 'Consider each … from …:' — followed by bullets such as 'Retain only … ⟨condition⟩', 'Determine the ⟨average/min/max⟩ … as ⟨{\"field\": @var}⟩', 'Collect … as …', or 'Subsequently, increment ⟨@total⟩ by one'; never 'If' or 'For' bullets",
+ beforeBlock: true);
+ }
+
+ var outcome = QueryPipeline.Execute(block, Env(), out var rowsIn, out var rowsOut);
+
+ if (outcome is EvalFailure failure)
+ {
+ return Backtrack(context, $"the query failed: {failure.Message}", failure.Hint, beforeBlock: true);
+ }
+
+ _program.Add(block);
+
+ var bound = (Bound)outcome;
+
+ foreach (var binding in bound.Bindings)
+ {
+ Bind(binding, source: "query");
+ }
+
+ var into = bound.Bindings.Length > 0 ? bound.Bindings[0].Var : "queryResult";
+ Trace(seq => new QueryExecuted(seq, into, rowsIn, rowsOut));
+
+ return null;
+ }
+
+ /// Checklist execution: first true branch runs; all guards trace; untaken branches cross out.
+ private StepResult? ExecuteConditional(StepContext context, ConditionalBlock block, bool addToProgram = true)
+ {
+ if (addToProgram)
+ {
+ _program.Add(block);
+ }
+
+ var takenIndex = -1;
+
+ for (var i = 0; i < block.Branches.Length; i++)
+ {
+ var branch = block.Branches[i];
+ bool guardValue;
+
+ if (branch.Guard is null)
+ {
+ guardValue = takenIndex < 0; // else-branch: taken iff nothing before it was
+ }
+ else
+ {
+ var outcome = Evaluator.Evaluate(branch.Guard, Env(), context.BuildCatalog());
+
+ switch (outcome)
+ {
+ case GuardResult g:
+ guardValue = g.Value;
+ break;
+ case EvalFailure failure:
+ // The underlying failure's teaching (e.g. object-extraction with the
+ // real field names) beats the generic advice — dropping it starved
+ // four identical guard failures of the fix (observed live).
+ return Backtrack(context, $"guard failed: {failure.Message}",
+ failure.Hint ?? "bind every value with ⟨@newVar is …⟩ BEFORE the first '- If' bullet", beforeBlock: true);
+ default:
+ return Backtrack(context, "a branch guard must be a comparison", null, beforeBlock: true);
+ }
+
+ var index = i;
+ var text = ConcreteRenderer.RenderStatement(branch.Guard, RenderMode.Formulas);
+ var value = guardValue;
+ Trace(seq => new GuardEvaluated(seq, index, text, value));
+ }
+
+ if (guardValue && takenIndex < 0)
+ {
+ takenIndex = i;
+ }
+ }
+
+ for (var i = 0; i < block.Branches.Length; i++)
+ {
+ if (i == takenIndex)
+ {
+ var index = i;
+ Trace(seq => new BranchTaken(seq, index));
+ continue;
+ }
+
+ var crossed = i;
+ var guardText = block.Branches[i].Guard is { } g2
+ ? ConcreteRenderer.RenderStatement(g2, RenderMode.Formulas)
+ : "otherwise";
+ Trace(seq => new BranchCrossedOut(seq, crossed, guardText));
+ }
+
+ if (takenIndex < 0)
+ {
+ return null; // no branch taken: legal — downstream reads of branch vars will fail → backtrack there
+ }
+
+ foreach (var item in block.Branches[takenIndex].Body)
+ {
+ if (item is not HedgeItem hedge)
+ {
+ continue;
+ }
+
+ var outcome = Evaluator.Evaluate(hedge.Statement, Env(), context.BuildCatalog());
+
+ switch (outcome)
+ {
+ case Bound bound:
+ foreach (var binding in bound.Bindings)
+ {
+ Bind(binding, SourceOf(hedge.Statement));
+ }
+
+ break;
+
+ case DisplayValue display:
+ Trace(seq => new DisplayShown(seq, hedge.ConcreteText, display.Formatted));
+ break;
+
+ case GuardResult { Value: true }:
+ break;
+
+ case GuardResult:
+ return Backtrack(context, $"the assertion [{hedge.ConcreteText}] is false",
+ "both sides are already bound to different values — earlier prose computed one of them; either drop the hedge (say it in plain prose) or bind a FRESH variable if you meant a new value");
+
+ case NeedTool:
+ return Backtrack(context,
+ "tool calls inside conditional branches are not supported yet",
+ "call the tool before the checklist and use its result in the branches");
+
+ case NeedRule:
+ return Backtrack(context,
+ "rule calls inside conditional branches are not supported yet",
+ "call the rule before the checklist and use its output in the branches");
+
+ // Same principle as the top-level skip: once the declared outputs are bound
+ // the answer exists — a failing later body hedge is noise, not a reason to
+ // unwind (observed live: a second, conflicting @btcLeft bind in the branch).
+ case EvalFailure when AnswerRequirementsMet:
+ Trace(seq => new ProtocolRepaired(seq, "post-answer-noise", hedge.ConcreteText));
+ break;
+
+ case EvalFailure failure:
+ return Backtrack(context, $"[{hedge.ConcreteText}] failed: {failure.Message}", failure.Hint);
+
+ // A silently-fallen-through outcome was exactly how NeedRule vanished here
+ // (review finding) — an unhandled kind must be a visible failure, never a drop.
+ default:
+ return Backtrack(context,
+ $"[{hedge.ConcreteText}] cannot run inside a conditional branch",
+ "compute it before the checklist and use the result in the branches");
+ }
+ }
+
+ return null;
+ }
+
+ private static string SourceOf(Statement statement) => statement switch
+ {
+ IsBinding => "is",
+ BindStmt => "bind",
+ _ => "match",
+ };
+
+ ///
+ /// True when the prose sentence containing the current hedge opens with a conditional word
+ /// ("If Sam has enough money, he … [hedge]"). The sentence is read from the program's
+ /// trailing comments — durable and correctly rewound by backtracking. It may flow across
+ /// inline DISPLAY hedges; a preceding binding hedge ends it (had that hedge shared the
+ /// sentence, it would have been caught itself). Checklist bullets never reach this check —
+ /// the recognizer routes '- If [guard]' into conditional blocks before hedges execute.
+ ///
+ private bool InConditionalSentence()
+ {
+ var text = "";
+
+ for (var i = _program.Count - 1; i >= 0 && _program.Count - i <= 4; i--)
+ {
+ if (_program[i] is Comment { Aside: false } comment)
+ {
+ text = comment.Text + text;
+ }
+ else if (_program[i] is not HedgeItem { Statement: DisplayStmt })
+ {
+ break;
+ }
+
+ if (text.IndexOfAny(SentenceBoundaries) >= 0)
+ {
+ break;
+ }
+ }
+
+ var cut = text.LastIndexOfAny(SentenceBoundaries);
+ var sentence = cut >= 0 ? text[(cut + 1)..] : text;
+
+ return System.Text.RegularExpressions.Regex.IsMatch(
+ sentence, @"^\s*(?:if|otherwise|unless|when)\b",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ }
+
+ private static readonly char[] SentenceBoundaries = ['.', '!', '?', ':', ';', '\n'];
+
+ // ---------------------------------------------------------- backtracking
+
+ public StepResult Backtrack(StepContext context, string reason, string? hint, bool fromRoot = false, bool beforeBlock = false)
+ {
+ var note = Sanitize(hint is null ? reason : $"{reason} — {hint}");
+
+ // Consecutive-identical-failure escalation. A fresh choice point is planted after
+ // every executed hedge, so a model looping "narrate, then fail the same way" retries
+ // at attempt 1 forever and burns the whole LLM budget (observed live: display @files,
+ // fail on unbound @pdfs, repeat). Identical repeats consume extra attempts on the
+ // CURRENT point only, exhausting it fast and forcing the rewind deeper — a genuinely
+ // different resume context — while the note tells the model it is repeating itself.
+ var reasonKey = Sanitize(reason);
+ reasonKey = reasonKey.Length > 200 ? reasonKey[..200] : reasonKey;
+
+ // Count matches across the whole (capped) note window, not just the tail: models also
+ // PING-PONG between two failure modes, and strictly-consecutive counting lets each one
+ // reset the other's escalation forever (observed live).
+ var repeats = _engineNotes.Count(n => n.StartsWith(reasonKey, StringComparison.Ordinal));
+
+ if (repeats > 0)
+ {
+ note = Sanitize(
+ $"{reason} — this exact failure has now happened {repeats + 1} times; the same approach will keep failing. " +
+ (hint ?? "take a genuinely different route"));
+ }
+
+ // Mode B has no choice points: the tree-of-thought degenerates to regenerating the
+ // WHOLE program with the (escalated) failure carried as feedback.
+ if (IsModeB)
+ {
+ return RegenerateWholeProgram(context, reason, note, repeats);
+ }
+
+ Trace(seq => new BacktrackStarted(seq, reason, _choicePoints.Length - 1,
+ _choicePoints.Length > 0 ? _choicePoints[^1].Attempt : 0));
+
+ if (fromRoot && _choicePoints.Length > 1)
+ {
+ // Whole-derivation properties (post-conditions) restart the derivation: keeping
+ // intermediate bindings would make every local retry fail identically.
+ _choicePoints = [_choicePoints[0]];
+ }
+
+ if (beforeBlock)
+ {
+ // A failed checklist/comprehension must rewind to BEFORE the block opened:
+ // retrying from a mid-block snapshot replays the buffered broken block before the
+ // model's correction can take effect (observed live — the fix got eaten).
+ while (_choicePoints.Length > 1 &&
+ _choicePoints[^1].Recognizer.Context != RecognizerContext.Top)
+ {
+ _choicePoints = _choicePoints.RemoveAt(_choicePoints.Length - 1);
+ }
+ }
+
+ var boost = repeats;
+
+ while (_choicePoints.Length > 0)
+ {
+ var cp = _choicePoints[^1];
+
+ if (cp.Attempt + 1 + boost < Budget.MaxAttemptsPerChoicePoint)
+ {
+ return RetryFrom(context, cp with { Attempt = cp.Attempt + 1 + boost }, note);
+ }
+
+ boost = 0; // acceleration applies to the repeating point only, not the pop cascade
+
+ _choicePoints = _choicePoints.RemoveAt(_choicePoints.Length - 1);
+ Budget = Budget with { BacktrackDepthUsed = Budget.BacktrackDepthUsed + 1 };
+
+ if (Budget.BacktrackDepthUsed > Budget.MaxBacktrackDepth)
+ {
+ return Restart(context, reason, note) ?? Fail($"backtracking depth exhausted: {reason}");
+ }
+ }
+
+ return Restart(context, reason, note) ?? Fail($"no alternatives left: {reason}");
+ }
+
+ ///
+ /// Mode B's coarse backtracking: reset everything to the question and ask for a fresh
+ /// whole program, the failure note riding in ENGINE NOTES. Bounded twice: the request
+ /// budget, and — because a whole-program generation costs minutes on slow models — the
+ /// SAME failure repeating MaxAttemptsPerChoicePoint times kills the derivation early
+ /// instead of grinding all 32 generations against an immovable wall (review finding).
+ ///
+ private StepResult RegenerateWholeProgram(StepContext context, string reason, string note, int repeats)
+ {
+ Trace(seq => new BacktrackStarted(seq, $"regenerate: {reason}", -1, _llmRequestCount));
+
+ if (repeats + 1 >= Budget.MaxAttemptsPerChoicePoint)
+ {
+ return Fail($"the same failure repeated {repeats + 1} times: {reason}");
+ }
+
+ if (_llmRequestCount >= Budget.MaxLlmRequests)
+ {
+ return Fail("LLM request budget exhausted");
+ }
+
+ _program = ImmutableArray.CreateBuilder();
+ _sigma = _question!.InitialBindings;
+ _prefill = "";
+ _recognizer = new LiterateRecognizer(RecognizerState.Initial);
+ _frames = [];
+ _choicePoints = [];
+ _modeBQueue = [];
+ _modeBPc = 0;
+ Pending = null;
+ _engineNotes = Append(_engineNotes, note);
+
+ IssueLlmRequest(context, newChoicePoint: false);
+
+ return Freeze();
+ }
+
+ ///
+ /// Tree-of-thought RESTART: the search is exhausted (choice points OR depth — observed
+ /// live for both: early query-compile failures killed a shallow stack with most of the
+ /// budget unspent, and a σ poisoned early made every honest checklist retry fail its
+ /// assertion until the depth ran out) but most of the request budget remains. Begin a
+ /// fresh derivation from the question — the accumulated engine notes carry the teachings
+ /// forward, so the new attempt starts smarter than the first did. Null when gated.
+ ///
+ private StepResult? Restart(StepContext context, string reason, string note)
+ {
+ if (_question is null || _llmRequestCount >= Budget.MaxLlmRequests * 3 / 4)
+ {
+ return null;
+ }
+
+ Trace(seq => new BacktrackStarted(seq, $"fresh start: {reason}", -1, 0));
+
+ _program = ImmutableArray.CreateBuilder();
+ _sigma = _question.InitialBindings;
+ _prefill = "";
+ _recognizer = new LiterateRecognizer(RecognizerState.Initial);
+ _frames = [];
+ _choicePoints = [];
+ Pending = null;
+ Budget = Budget with { BacktrackDepthUsed = 0 };
+ _engineNotes = Append(_engineNotes, note);
+
+ IssueLlmRequest(context, newChoicePoint: true);
+
+ return Freeze();
+ }
+
+ private StepResult RetryFrom(StepContext context, ChoicePoint cp, string note)
+ {
+ if (_llmRequestCount >= Budget.MaxLlmRequests)
+ {
+ return Fail("LLM request budget exhausted");
+ }
+
+ var discarded = _prefill.Length > cp.AssistantPrefill.Length ? _prefill[cp.AssistantPrefill.Length..] : "";
+ if (discarded.Length > 0)
+ {
+ Trace(seq => new BranchAbandoned(seq, discarded));
+ }
+
+ // Rewind to the snapshot.
+ var kept = ImmutableArray.CreateBuilder(cp.ProgramLength);
+ for (var i = 0; i < cp.ProgramLength; i++)
+ {
+ kept.Add(_program[i]);
+ }
+
+ _program = kept;
+ _sigma = cp.Sigma;
+ _recognizer = new LiterateRecognizer(cp.Recognizer);
+ _choicePoints = _choicePoints.SetItem(_choicePoints.Length - 1, cp);
+ _engineNotes = Append(_engineNotes, note);
+ Pending = null;
+ _frames = []; // choice points are only taken at top level; abandon any rule activation
+
+ // Inline steering, in the model's own voice (recency matters for small models). It
+ // becomes part of the trace/program like any other prose.
+ var steering = $" That didn't work ({note}). Let me try a different approach. ";
+ _prefill = cp.AssistantPrefill + steering;
+ _program.Add(new Comment(steering, Aside: true));
+
+ IssueLlmRequest(context, newChoicePoint: false);
+
+ return Freeze();
+ }
+
+ ///
+ /// Engine notes carry names, codes, and schema keys — never σ values — and NEVER square
+ /// brackets: bracketed hedges inside a steering sentence get parroted by small models and
+ /// then executed by the scanner, creating a failure loop (observed live with granite3.3).
+ ///
+ private static string Sanitize(string note)
+ {
+ var bracketFree = note
+ .Replace('[', '⟨')
+ .Replace(']', '⟩');
+
+ return bracketFree.Length > 400 ? bracketFree[..400] + "…" : bracketFree;
+ }
+
+ /// Keeps the most recent notes only — old notes stop being useful and bloat the prompt.
+ private static ImmutableArray Append(ImmutableArray notes, string note)
+ {
+ var appended = notes.Add(note);
+ return appended.Length > 4 ? appended.RemoveRange(0, appended.Length - 4) : appended;
+ }
+
+ private StepResult Fail(string reason)
+ {
+ FailInPlace(reason);
+ return Freeze();
+ }
+
+ /// Marks the builder failed without freezing — for guards inside void paths.
+ private void FailInPlace(string reason)
+ {
+ _phase = new FailedPhase(reason);
+ _pendingRequestIds = [];
+ Pending = null;
+ _frames = [];
+ Trace(seq => new DerivationFailed(seq, reason));
+ _effects.Add(new FailedEffect(reason));
+ }
+
+ // ---------------------------------------------------------- contracts
+
+ ///
+ /// Evaluates one contract clause. Returns null when it passes; a final result otherwise
+ /// (pre-violations fail the derivation, post-violations backtrack — the model's plan was
+ /// wrong, so an alternative derivation may still satisfy the contract).
+ ///
+ public StepResult? CheckContract(StepContext context, ContractClauseText clause, bool isPre)
+ {
+ var parsed = HedgeParser.Parse(clause.Condition);
+
+ if (!parsed.Success)
+ {
+ return Fail($"cannot parse the {(isPre ? "pre" : "post")}-condition [{clause.Condition}]: {parsed.Error}");
+ }
+
+ var outcome = Evaluator.Evaluate(parsed.Statement!, Env(), context.BuildCatalog());
+ var passed = outcome is GuardResult { Value: true };
+
+ Trace(seq => new ContractChecked(seq, isPre, clause.Condition, passed));
+
+ if (passed)
+ {
+ return null;
+ }
+
+ var explanation = outcome is EvalFailure failure
+ ? $"[{clause.Condition}] could not be evaluated: {failure.Message}"
+ : $"[{clause.Condition}] is violated — {clause.Rationale}";
+
+ return isPre
+ ? Fail($"pre-condition failed: {explanation}")
+ : Backtrack(context, $"post-condition failed: {explanation}",
+ "derive the answer a different way so the contract holds", fromRoot: true);
+ }
+
+ // ---------------------------------------------------------- completion
+
+ public StepResult Complete()
+ {
+ var env = Env();
+ var answer = AnswerAssembler.Assemble(_program, env);
+
+ if (_question is not null && !_question.ExpectedOutputs.IsEmpty)
+ {
+ var lines = _question.ExpectedOutputs
+ .Where(name => _sigma.ContainsKey(name))
+ .Select(name => $"@{name} = {Evaluator.FormatForDisplay(env.GetNode(name))}")
+ .ToList();
+
+ if (lines.Count > 0)
+ {
+ answer = answer + "\n" + string.Join("\n", lines);
+ }
+ }
+
+ if (_question is { LearnRuleOnSuccess: true })
+ {
+ // Learning is best-effort: a compiler surprise on a live program shape must
+ // never block the answer.
+ try
+ {
+ LearnRule();
+ }
+ catch (Exception ex)
+ {
+ Trace(seq => new ProtocolRepaired(seq, "learning-skipped", Sanitize(ex.Message)));
+ }
+ }
+
+ _phase = new Completed(answer);
+ _pendingRequestIds = [];
+ Trace(seq => new AnswerAssembled(seq, answer));
+ _effects.Add(new EmitAnswer(answer));
+
+ return Freeze();
+ }
+
+ ///
+ /// Tennent abstraction, mechanically: the successful derivation (taken paths, engine
+ /// asides stripped) becomes a rule whose In-parameters are the question's initial
+ /// bindings and whose Out-parameters are its declared outputs. The DefineRule payload
+ /// carries BOTH forms — the executable IR and the Bonsai expression tree (the durable,
+ /// language-agnostic intentional representation).
+ ///
+ private void LearnRule()
+ {
+ var question = _question!;
+ var name = question.RuleName ?? "learned_rule";
+
+ var parameters = ImmutableArray.CreateBuilder();
+
+ foreach (var input in question.InitialBindings.Keys.OrderBy(k => k, StringComparer.Ordinal))
+ {
+ parameters.Add(new RuleParam(input, ParamMode.In));
+ }
+
+ foreach (var output in question.ExpectedOutputs)
+ {
+ parameters.Add(new RuleParam(output, ParamMode.Out));
+ }
+
+ var description = question.Text.Length > 140 ? question.Text[..140] + "…" : question.Text;
+ var body = _program.Where(item => item is not Comment { Aside: true }).ToImmutableArray();
+
+ var rule = new RuleDefinition(
+ new RuleSignature(name, parameters.ToImmutable(), description),
+ question.Text,
+ new UniversalisProgram(body, [], []));
+
+ var bonsai = BonsaiSerialization.ToBonsaiJson(BonsaiCompiler.Compile(rule));
+
+ var payload = System.Text.Json.JsonSerializer.Serialize(new Dictionary
+ {
+ ["ir"] = IrJson.Serialize(rule),
+ ["bonsai"] = bonsai,
+ });
+
+ Trace(seq => new RuleLearned(seq, name,
+ $"{name}({string.Join(", ", parameters.Select(p => $"{p.Name}: {(p.Mode == ParamMode.In ? "in" : "out")}"))})"));
+
+ _effects.Add(new DefineRule(name, payload));
+ }
+
+ public StepResult Freeze() => new(
+ new DerivationState(
+ _conversationId,
+ _seq,
+ _phase,
+ _pendingRequestIds,
+ _question,
+ _program.ToImmutable(),
+ _sigma,
+ _choicePoints,
+ Budget,
+ _recognizer.State,
+ _prefill,
+ _engineNotes,
+ Pending,
+ _llmRequestCount,
+ _frames)
+ {
+ ModeBQueue = _modeBQueue,
+ ModeBPc = _modeBPc,
+ },
+ _effects.ToImmutable());
+ }
+}
\ No newline at end of file
diff --git a/src/Automind.Kernel/Prompting/PromptAssembler.cs b/src/Automind.Kernel/Prompting/PromptAssembler.cs
new file mode 100644
index 0000000..83be5ae
--- /dev/null
+++ b/src/Automind.Kernel/Prompting/PromptAssembler.cs
@@ -0,0 +1,330 @@
+using System.Collections.Immutable;
+using System.Text;
+
+using Automind.Kernel.Contract;
+
+using Universalis.Core.Evaluation;
+using Universalis.Core.Ir;
+
+namespace Automind.Kernel.Prompting;
+
+///
+/// Builds the LAWS-first system prompt for an 8B model: terse rules up front, byte-exact few-shot
+/// transcripts in the middle (exemplars dominate small-model behavior), and a recency reminder —
+/// plus value-sanitized engine notes accumulated by backtracking — at the very end.
+///
+public static class PromptAssembler
+{
+ public static PromptState Build(
+ QuestionEnvelope question,
+ StepContext context,
+ string assistantPrefill,
+ ImmutableArray engineNotes,
+ int attempt,
+ string conversationId,
+ string requestId)
+ {
+ return new PromptState(
+ BuildSystemPrompt(question, context, engineNotes),
+ BuildQuestionText(question),
+ assistantPrefill,
+ HardStopSequences: ["\nQuestion:", "\nuser:", "\nUser:"],
+ MaxSegmentTokens: 512,
+ Temperature: Math.Min(0.2 + 0.3 * attempt, 0.8),
+ Seed: StableHash(conversationId + "|" + requestId));
+ }
+
+ private static string BuildQuestionText(QuestionEnvelope question)
+ {
+ if (question.InitialBindings.IsEmpty)
+ {
+ return question.Text;
+ }
+
+ var sb = new StringBuilder(question.Text);
+ sb.Append("\n\nGiven values:");
+
+ foreach (var (name, json) in question.InitialBindings.OrderBy(b => b.Key, StringComparer.Ordinal))
+ {
+ sb.Append("\n- @").Append(name).Append(" = ").Append(json);
+ }
+
+ return sb.ToString();
+ }
+
+ private static string BuildSystemPrompt(QuestionEnvelope question, StepContext context, ImmutableArray engineNotes)
+ {
+ var sb = new StringBuilder();
+
+ sb.Append(
+ """
+ You are Automind, a reasoning engine that answers questions by writing literate Universalis:
+ short natural-language sentences with executable code only inside [ ... ] hedges.
+
+ LAWS — follow these exactly:
+ 1. Code appears ONLY inside [...] hedges. Never use [ or ] in ordinary prose.
+ 2. You NEVER know or write tool results. For every tool OUTPUT argument write a FRESH
+ @variable, e.g. [WEATHER("Palo Alto", @weatherPaloAlto)]. Never write a value where
+ an output belongs.
+ 3. Never invent the value of any @variable. To show a value to the user, write the
+ variable alone in a hedge: [@profitPct].
+ 4. Never reuse an @variable name for a different value; invent a new lowerCamelCase name.
+ 5. Each hedge contains exactly ONE thing: one tool call, one calculation, or one variable.
+ Never join two calls with a comma — write each call in its own [ ... ] hedge, one at a
+ time, continuing the sentence naturally after each ].
+ 6. Arithmetic: [@x is ]. Comparisons/conditions as bullets: "- If [@a >= @b], then ...".
+ Destructure JSON tool results with patterns: [TOOL(@in, { ... "field": @out ... })].
+ 7. There are NO loops and none are needed: if a tool takes ONE item but your variable
+ holds a LIST, call the tool ONCE with the list variable — the engine automatically
+ applies it to every element and returns a list of results.
+ 8. ACT, never describe: every step must be an executable [ ... ] hedge. When the answer
+ has been fully shown via [@...] display hedges, finish with one short closing
+ sentence and STOP.
+
+ """);
+
+ AppendCatalogSections(sb, question, context);
+
+ sb.Append(
+ """
+
+ EXAMPLES of perfect answers:
+
+ Question: What is the weather between Mountain View and Menlo Park?
+ Answer: The city between Mountain View and Menlo Park is Palo Alto. Let's find the weather
+ there [WEATHER("Palo Alto", @weatherPaloAlto)]. The current weather in Palo Alto is
+ [@weatherPaloAlto]. That answers the question.
+
+ Question: Alice bought a kilo of apples for $10. She sold them for $17. How much percent
+ profit or loss did Alice make?
+ Answer: The apples cost $[@buyPrice is 10] and sold for $[@sellPrice is 17], so the profit
+ is [@profit is @sellPrice - @buyPrice] dollars. The profit percentage is therefore
+ [@profitPct is (@profit / @buyPrice) * 100]. Alice made a profit of [@profitPct] percent.
+
+ Question: Kim has $250 and a ticket costs $180. Can Kim buy it, and how much remains?
+ Answer: Kim has [@cash is 250] dollars and the ticket costs [@price is 180] dollars. Now decide:
+ - If [@cash >= @price], then Kim buys the ticket and keeps [@left is @cash - @price] dollars.
+ - Otherwise, Kim skips the ticket and keeps [@left = @cash] dollars.
+ Kim ends up with [@left] dollars.
+
+ Question: What did IBM stock close at?
+ Answer: Let's get a quote [STOCK("IBM", { ... "close": @closePrice ... })]. IBM closed
+ at [@closePrice] dollars.
+
+ Question: How many of the customers in @customers live in Palo Alto? (given values: @customers)
+ Answer: Consider each customer [@c = { ... "city": @city ... }] from [@customers]:
+ - Retain only customers [@c] where [@city = "Palo Alto"].
+ - Subsequently, increment [@total] by one for each retained customer [@c].
+ There are [@total] such customers.
+
+ Question: Convert every file in the folder @folder to PDF. (given values: @folder)
+ Answer: First, list the files [LIST_FILES(@folder, @files)]. The conversion tool takes
+ ONE file, so I call it once with the whole list [TO_PDF(@files, @pdfs)] and the engine
+ converts every file automatically. The new files are [@pdfs]. All done.
+
+ REMINDER: outputs are FRESH @variables, never values (law 2). Show values only via
+ [@variable] display hedges (law 3). After each ] the call is DONE and its @variable
+ holds the result — NEVER repeat a call; continue with the next step or display the
+ result. Stop after a short closing sentence (law 7).
+ """);
+
+ AppendEngineNotes(sb, engineNotes);
+
+ return sb.ToString();
+ }
+
+ // ================================================================ Mode B: whole-program synthesis
+
+ ///
+ /// The Mode B prompt: ONE structured completion returns the papers' {comment|expression}[]
+ /// interchange form (wrapped in a {"program": …} root for schema-constrained decoding).
+ /// Same laws, same tool/rule catalog, same engine-note feedback — only the delivery differs:
+ /// the model writes the complete program up front instead of being intercepted per hedge.
+ ///
+ public static PromptState BuildWholeProgram(
+ QuestionEnvelope question,
+ StepContext context,
+ ImmutableArray engineNotes,
+ int attempt,
+ string conversationId,
+ string requestId)
+ {
+ var sb = new StringBuilder();
+
+ sb.Append(
+ """
+ You are Automind, a reasoning engine. You answer by writing ONE complete literate
+ Universalis program as JSON. You never execute anything and never know any result —
+ the engine runs your program afterwards and fills in every value.
+
+ OUTPUT FORMAT — exactly one JSON object, nothing else:
+ {"program": [ {"comment": "prose"}, {"expression": "code"}, … ]}
+ The items read top-to-bottom as one literate document: prose in "comment" items,
+ executable code in "expression" items.
+
+ LAWS — follow these exactly:
+ 1. Code appears ONLY in "expression" items — never write brackets [ ] anywhere.
+ 2. You NEVER know or write tool results. Every tool OUTPUT argument is a FRESH
+ @variable. Never write a value where an output belongs.
+ 3. Never invent the value of any @variable. To show a value in the answer, add an
+ expression holding the variable alone: {"expression": "@profitPct"}.
+ 4. Each expression contains exactly ONE thing: one tool call, one calculation
+ "@x is ", one condition, or one display variable. Never join two.
+ 5. Decisions are checklists: a comment starting "- If" then the condition expression,
+ the branch prose and its binding expression; then "- Otherwise, …" with its
+ binding expression. EVERY branch must bind the same variable.
+ 6. There are NO loops: a tool that takes ONE item accepts a LIST variable — the
+ engine applies it to every element automatically.
+ 7. End by displaying each required output variable, then one short closing comment.
+
+ """);
+
+ AppendCatalogSections(sb, question, context);
+
+ sb.Append(
+ """
+
+ EXAMPLE — question: "Kim has $250 and a ticket costs $180. Can Kim buy it, and how
+ much remains? Bind the remainder as @left." Perfect answer:
+ {"program": [
+ {"comment": "Kim has"},
+ {"expression": "@cash is 250"},
+ {"comment": "dollars and the ticket costs"},
+ {"expression": "@price is 180"},
+ {"comment": "dollars. Now decide:"},
+ {"comment": "- If"},
+ {"expression": "@cash >= @price"},
+ {"comment": ", then Kim buys the ticket and keeps"},
+ {"expression": "@left is @cash - @price"},
+ {"comment": "dollars."},
+ {"comment": "- Otherwise, Kim skips the ticket and keeps"},
+ {"expression": "@left = @cash"},
+ {"comment": "dollars."},
+ {"comment": "Kim ends up with"},
+ {"expression": "@left"},
+ {"comment": "dollars."}
+ ]}
+
+ EXAMPLE — question: "Alice bought apples for $10 and sold them for $17. What is the
+ profit percentage? Bind it as @profitPct." Perfect answer:
+ {"program": [
+ {"comment": "The apples cost"},
+ {"expression": "@buyPrice is 10"},
+ {"comment": "dollars and sold for"},
+ {"expression": "@sellPrice is 17"},
+ {"comment": "dollars, so the profit is"},
+ {"expression": "@profit is @sellPrice - @buyPrice"},
+ {"comment": "dollars. The profit percentage is"},
+ {"expression": "@profitPct is (@profit / @buyPrice) * 100"},
+ {"comment": ". Alice made a profit of"},
+ {"expression": "@profitPct"},
+ {"comment": "percent."}
+ ]}
+
+ REMINDER: outputs are FRESH @variables, never values (law 2). The program must be
+ COMPLETE — the engine executes it exactly once, top to bottom.
+ """);
+
+ AppendEngineNotes(sb, engineNotes);
+
+ return new PromptState(
+ sb.ToString(),
+ BuildQuestionText(question),
+ AssistantPrefill: "",
+ HardStopSequences: [],
+ // Grows across attempts: a program truncated at a fixed cap regenerates into the
+ // SAME ceiling forever (review finding) — and thinking models spend part of the
+ // budget before the first JSON byte.
+ MaxSegmentTokens: 1024 + 512 * Math.Min(attempt, 2),
+ Temperature: Math.Min(0.2 + 0.3 * attempt, 0.8),
+ Seed: StableHash(conversationId + "|" + requestId),
+ WholeProgram: true);
+ }
+
+ // ================================================================ shared sections
+
+ private static void AppendCatalogSections(StringBuilder sb, QuestionEnvelope question, StepContext context)
+ {
+ sb.Append("\nTOOLS — the only predicates you may call:\n");
+
+ foreach (var tool in context.Tools)
+ {
+ sb.Append("- ").Append(Describe(tool.Signature)).Append(" — ").Append(tool.Description).Append('\n');
+ }
+
+ // RAG as virtual memory: when the pager recalled a subset, advertise ONLY that subset —
+ // the rule library scales beyond what always-in-context could hold. (Execution can still
+ // dispatch ANY stored rule; this only shapes what the model sees.)
+ ImmutableArray advertisedRules = question.RecalledRules is { } recalled
+ ? [.. context.Rules.Where(r => recalled.Contains(r.Signature.Name, StringComparer.OrdinalIgnoreCase))]
+ : context.Rules;
+
+ if (!advertisedRules.IsEmpty)
+ {
+ sb.Append("\nLEARNED RULES — reusable like tools:\n");
+
+ foreach (var rule in advertisedRules)
+ {
+ sb.Append("- ").Append(Describe(ToSignature(rule))).Append(" — ").Append(rule.Signature.Description).Append('\n');
+ }
+
+ // The invocation affordance: without it, small models re-derive a rule's BODY from
+ // its description instead of calling it (observed live with a stored query rule).
+ // NO @-tokens in this sentence: models parrot them into answer prose and the
+ // phantom-variable guard then vetoes an otherwise-correct answer (observed live
+ // with '@in' — three good completions rejected, budget exhausted).
+ sb.Append("Invoke a learned rule exactly like a tool, in ONE hedge — inputs first, a FRESH variable as the final output — and never re-derive what a rule already does.\n");
+ }
+
+ if (!question.ContextChunks.IsEmpty)
+ {
+ sb.Append("\nCONTEXT — background knowledge that may help answer this question:\n");
+
+ foreach (var chunk in question.ContextChunks)
+ {
+ var text = chunk.Text.Length > 900 ? chunk.Text[..900] + "…" : chunk.Text;
+ sb.Append("• ").Append(chunk.Title).Append(": ").Append(text.ReplaceLineEndings(" ")).Append('\n');
+ }
+ }
+ }
+
+ private static void AppendEngineNotes(StringBuilder sb, ImmutableArray engineNotes)
+ {
+ if (engineNotes.IsEmpty)
+ {
+ return;
+ }
+
+ sb.Append("\n\nENGINE NOTES from earlier attempts:\n");
+
+ foreach (var note in engineNotes)
+ {
+ sb.Append("- ").Append(note).Append('\n');
+ }
+ }
+
+ private static string Describe(PredicateSignature signature) =>
+ $"{signature.Name}({string.Join(", ", signature.Params.Select(p => $"{p.Name}: {(p.Mode == ParamMode.In ? "in" : "out")}"))})";
+
+ private static PredicateSignature ToSignature(RuleDefinition rule) => new(
+ rule.Signature.Name,
+ [.. rule.Signature.Params.Select(p => new PredicateParam(p.Name, p.Mode))],
+ rule.Signature.Description,
+ IsRule: true);
+
+ /// Deterministic FNV-1a hash — reproducible seeds without .
+ public static int StableHash(string text)
+ {
+ unchecked
+ {
+ var hash = 2166136261u;
+
+ foreach (var c in text)
+ {
+ hash = (hash ^ c) * 16777619u;
+ }
+
+ return (int)(hash & 0x7FFFFFFF);
+ }
+ }
+}
diff --git a/src/Automind.Kernel/Prompting/PromptState.cs b/src/Automind.Kernel/Prompting/PromptState.cs
new file mode 100644
index 0000000..5d93c46
--- /dev/null
+++ b/src/Automind.Kernel/Prompting/PromptState.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+
+namespace Automind.Kernel.Prompting;
+
+///
+/// Everything the LLM bridge needs to issue one generation segment. Serialized into the
+/// RequestLlm effect; the bridge renders it to chat messages, streams with the hedge
+/// scanner, and cuts at the balanced hedge close.
+///
+public sealed record PromptState(
+ string SystemPrompt,
+ string QuestionText,
+ string AssistantPrefill,
+ ImmutableArray HardStopSequences,
+ int MaxSegmentTokens,
+ double Temperature,
+ int Seed,
+ bool WholeProgram = false)
+{
+ private static readonly JsonSerializerOptions s_json = new()
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ public string ToJson() => JsonSerializer.Serialize(this, s_json);
+
+ public static PromptState FromJson(string json) =>
+ JsonSerializer.Deserialize(json, s_json) ?? throw new JsonException("null prompt state");
+}
diff --git a/src/Automind.Mcp/Automind.Mcp.csproj b/src/Automind.Mcp/Automind.Mcp.csproj
new file mode 100644
index 0000000..f0d09db
--- /dev/null
+++ b/src/Automind.Mcp/Automind.Mcp.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Automind.Mcp/McpBridgedTool.cs b/src/Automind.Mcp/McpBridgedTool.cs
new file mode 100644
index 0000000..1dc9982
--- /dev/null
+++ b/src/Automind.Mcp/McpBridgedTool.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+using Automind.Tools;
+
+using Universalis.Core.Evaluation;
+
+namespace Automind.Mcp;
+
+///
+/// One MCP tool as a Universalis predicate. Invocation flows through the engine's durable
+/// InvokeTool effect like every other tool — the hedge protocol, NOT the chat client's native
+/// function calling, is the tool-calling mechanism (register discipline, durability, and
+/// backtracking all depend on it). An MCP error result throws, which the driver converts to
+/// ToolFailed and the kernel to a backtrack.
+///
+public sealed class McpBridgedTool : ITool
+{
+ // Same source name as the substrate's — listeners subscribe by NAME; this project
+ // deliberately has no Automind.Reaqtor reference.
+ private static readonly System.Diagnostics.ActivitySource s_activity = new("Automind.Substrate");
+
+ private readonly McpClientTool _tool;
+ private readonly string _serverName;
+
+ public McpBridgedTool(McpClientTool tool, string serverName, PredicateSignature signature, bool isIdempotent)
+ {
+ _tool = tool;
+ _serverName = serverName;
+ Signature = signature;
+ IsIdempotent = isIdempotent;
+ }
+
+ public PredicateSignature Signature { get; }
+
+ public string Description => _tool.Description is { Length: > 0 } d ? d : _tool.Name;
+
+ public bool IsIdempotent { get; }
+
+ public async Task> InvokeAsync(string argsJson, CancellationToken cancellationToken)
+ {
+ using var activity = s_activity.StartActivity("mcp.call");
+ activity?.SetTag("automind.mcp.server", _serverName);
+ activity?.SetTag("automind.mcp.tool", _tool.Name);
+
+ var arguments = new Dictionary(StringComparer.Ordinal);
+
+ using (var doc = JsonDocument.Parse(argsJson))
+ {
+ foreach (var property in doc.RootElement.EnumerateObject())
+ {
+ arguments[property.Name] = property.Value.Clone();
+ }
+ }
+
+ var result = await _tool.CallAsync(arguments, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.IsError == true)
+ {
+ var reason = string.Join("\n", result.Content.OfType().Select(block => block.Text));
+ activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, reason);
+ throw new InvalidOperationException(reason.Length > 0 ? reason : $"MCP tool '{_tool.Name}' reported an error");
+ }
+
+ return [McpPredicateMapper.MapResult(result)];
+ }
+}
diff --git a/src/Automind.Mcp/McpPredicateMapper.cs b/src/Automind.Mcp/McpPredicateMapper.cs
new file mode 100644
index 0000000..eb3c19d
--- /dev/null
+++ b/src/Automind.Mcp/McpPredicateMapper.cs
@@ -0,0 +1,179 @@
+using System.Collections.Immutable;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+using ModelContextProtocol.Protocol;
+
+using Universalis.Core.Evaluation;
+using Universalis.Core.Ir;
+
+namespace Automind.Mcp;
+
+///
+/// Pure mapping between the MCP tool shape and the Universalis predicate shape. The predicate
+/// NAME is uppercased to the catalog convention (search_files → SEARCH_FILES),
+/// but parameter names stay VERBATIM — the engine serializes call arguments keyed by parameter
+/// name, and those keys must match the MCP input schema exactly.
+///
+public static class McpPredicateMapper
+{
+ /// MCP tool name → predicate name (UPPER_SNAKE, leading letter guaranteed).
+ public static string ToPredicateName(string mcpName)
+ {
+ var sb = new StringBuilder(mcpName.Length);
+
+ foreach (var c in mcpName)
+ {
+ sb.Append(char.IsLetterOrDigit(c) ? char.ToUpperInvariant(c) : '_');
+ }
+
+ var name = sb.ToString().Trim('_');
+
+ return name.Length == 0 || !char.IsLetter(name[0]) ? "MCP_" + name : name;
+ }
+
+ ///
+ /// Input schema → fixed-mode signature: the REQUIRED properties (in schema document order)
+ /// become In-parameters, plus one trailing Out-parameter for the result. Optional MCP
+ /// parameters are dropped — Universalis is fixed-arity, and a narrower tool confuses a
+ /// small model less than optional arity would (documented v1 limitation).
+ ///
+ public static PredicateSignature ToSignature(string mcpName, string? description, JsonElement inputSchema)
+ {
+ var required = new HashSet(StringComparer.Ordinal);
+
+ if (inputSchema.ValueKind == JsonValueKind.Object &&
+ inputSchema.TryGetProperty("required", out var requiredArray) &&
+ requiredArray.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var name in requiredArray.EnumerateArray())
+ {
+ if (name.ValueKind == JsonValueKind.String)
+ {
+ required.Add(name.GetString()!);
+ }
+ }
+ }
+
+ var parameters = ImmutableArray.CreateBuilder();
+
+ if (inputSchema.ValueKind == JsonValueKind.Object &&
+ inputSchema.TryGetProperty("properties", out var properties) &&
+ properties.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in properties.EnumerateObject())
+ {
+ if (required.Contains(property.Name))
+ {
+ // An array-typed parameter must receive the WHOLE list: without
+ // AcceptsCollection the engine zip-lifts a list argument into one
+ // invocation per element and the server rejects every scalar
+ // (review finding).
+ parameters.Add(new PredicateParam(property.Name, ParamMode.In, IsArrayTyped(property.Value)));
+ }
+ }
+ }
+
+ var outName = parameters.Any(p => p.Name == "result") ? "outValue" : "result";
+ parameters.Add(new PredicateParam(outName, ParamMode.Out));
+
+ return new PredicateSignature(
+ ToPredicateName(mcpName),
+ parameters.ToImmutable(),
+ description ?? mcpName);
+ }
+
+ /// "type": "array" — as a plain string or inside a type union (["array","null"]).
+ private static bool IsArrayTyped(JsonElement propertySchema)
+ {
+ if (propertySchema.ValueKind != JsonValueKind.Object ||
+ !propertySchema.TryGetProperty("type", out var type))
+ {
+ return false;
+ }
+
+ return type.ValueKind switch
+ {
+ JsonValueKind.String => type.GetString() == "array",
+ JsonValueKind.Array => type.EnumerateArray().Any(t => t.ValueKind == JsonValueKind.String && t.GetString() == "array"),
+ _ => false,
+ };
+ }
+
+ ///
+ /// True when the input schema uses composition the flat mapper cannot see: allOf/anyOf/
+ /// oneOf/$ref at the top level, or required names with no matching top-level property.
+ /// Bridging such a tool would advertise a silently WRONG arity — better to skip it with a
+ /// warning at connect time than fail every invocation (review finding).
+ ///
+ public static bool HasUnmappedComposition(JsonElement inputSchema)
+ {
+ if (inputSchema.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ foreach (var keyword in (string[])["allOf", "anyOf", "oneOf", "$ref"])
+ {
+ if (inputSchema.TryGetProperty(keyword, out _))
+ {
+ return true;
+ }
+ }
+
+ if (inputSchema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array)
+ {
+ var hasProperties = inputSchema.TryGetProperty("properties", out var properties) &&
+ properties.ValueKind == JsonValueKind.Object;
+
+ foreach (var name in required.EnumerateArray())
+ {
+ if (name.ValueKind == JsonValueKind.String &&
+ (!hasProperties || !properties.TryGetProperty(name.GetString()!, out _)))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Recovery re-issues in-flight idempotent calls with the same request id; an MCP tool with
+ /// unknown side effects must NOT double-fire, so only the server's own annotations opt in.
+ ///
+ public static bool IsIdempotent(ToolAnnotations? annotations) =>
+ annotations?.ReadOnlyHint == true || annotations?.IdempotentHint == true;
+
+ ///
+ /// Result → one JSON value: prefer the server's structured content (it IS JSON); otherwise
+ /// join the text blocks — passed through RAW when the text parses as JSON (so pattern
+ /// destructuring works on JSON-returning tools), string-encoded when it is plain prose.
+ ///
+ public static string MapResult(CallToolResult result)
+ {
+ if (result.StructuredContent is { } structured)
+ {
+ return structured.GetRawText();
+ }
+
+ var text = string.Join("\n", result.Content.OfType().Select(block => block.Text));
+
+ if (text.Length == 0)
+ {
+ return "null";
+ }
+
+ try
+ {
+ _ = JsonNode.Parse(text);
+ return text;
+ }
+ catch (JsonException)
+ {
+ return JsonSerializer.Serialize(text);
+ }
+ }
+}
diff --git a/src/Automind.Mcp/McpToolBridge.cs b/src/Automind.Mcp/McpToolBridge.cs
new file mode 100644
index 0000000..e47660a
--- /dev/null
+++ b/src/Automind.Mcp/McpToolBridge.cs
@@ -0,0 +1,147 @@
+using ModelContextProtocol.Client;
+
+using Automind.Tools;
+
+namespace Automind.Mcp;
+
+///
+/// Connects MCP servers and surfaces their tools as Universalis predicates. One bridge owns
+/// every client session (a stdio session owns its child process); dispose the bridge to shut
+/// them down. Connection is fail-soft per server: a server that will not start costs a warning,
+/// never the derivation host.
+///
+public sealed class McpToolBridge : IAsyncDisposable
+{
+ private readonly List _clients = [];
+ private readonly List _tools = [];
+
+ private McpToolBridge()
+ {
+ }
+
+ public IReadOnlyList Tools => _tools;
+
+ ///
+ /// : one command line per stdio server ("npx -y @scope/server",
+ /// "dotnet path/to/Server.dll"). filters by MCP or predicate
+ /// name, case-insensitive; null bridges everything.
+ ///
+ public static async Task ConnectAsync(
+ IEnumerable serverCommands,
+ IReadOnlySet? allowlist,
+ Action log,
+ CancellationToken cancellationToken = default)
+ {
+ var bridge = new McpToolBridge();
+
+ foreach (var commandLine in serverCommands)
+ {
+ var parts = SplitCommandLine(commandLine);
+
+ if (parts.Length == 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ var client = await McpClient.CreateAsync(
+ new StdioClientTransport(new StdioClientTransportOptions
+ {
+ Command = parts[0],
+ Arguments = [.. parts.Skip(1)],
+ Name = Path.GetFileNameWithoutExtension(parts[0]),
+ }),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ bridge._clients.Add(client);
+
+ var serverName = client.ServerInfo?.Name ?? parts[0];
+
+ foreach (var tool in await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
+ {
+ // A schema this mapper cannot see through (allOf/$ref composition) would
+ // bridge with a silently wrong arity and fail every invocation — skip loudly.
+ if (McpPredicateMapper.HasUnmappedComposition(tool.JsonSchema))
+ {
+ log($"MCP tool '{tool.Name}' uses schema composition (allOf/$ref) the bridge cannot map — skipped");
+ continue;
+ }
+
+ var signature = McpPredicateMapper.ToSignature(tool.Name, tool.Description, tool.JsonSchema);
+
+ if (allowlist is not null &&
+ !allowlist.Contains(tool.Name) &&
+ !allowlist.Contains(signature.Name))
+ {
+ continue;
+ }
+
+ bridge._tools.Add(new McpBridgedTool(
+ tool,
+ serverName,
+ signature,
+ McpPredicateMapper.IsIdempotent(tool.ProtocolTool.Annotations)));
+ }
+ }
+ catch (Exception ex)
+ {
+ log($"MCP server '{commandLine}' skipped: {ex.Message}");
+ }
+ }
+
+ return bridge;
+ }
+
+ /// Splits a command line on whitespace, honoring double quotes.
+ public static string[] SplitCommandLine(string commandLine)
+ {
+ var parts = new List();
+ var current = new System.Text.StringBuilder();
+ var quoted = false;
+
+ foreach (var c in commandLine)
+ {
+ if (c == '"')
+ {
+ quoted = !quoted;
+ }
+ else if (char.IsWhiteSpace(c) && !quoted)
+ {
+ if (current.Length > 0)
+ {
+ parts.Add(current.ToString());
+ current.Clear();
+ }
+ }
+ else
+ {
+ current.Append(c);
+ }
+ }
+
+ if (current.Length > 0)
+ {
+ parts.Add(current.ToString());
+ }
+
+ return [.. parts];
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var client in _clients)
+ {
+ try
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Shutdown is best-effort; a dead child process is already the goal state.
+ }
+ }
+
+ _clients.Clear();
+ }
+}
diff --git a/src/Automind.Memory/Automind.Memory.csproj b/src/Automind.Memory/Automind.Memory.csproj
new file mode 100644
index 0000000..fbd87e6
--- /dev/null
+++ b/src/Automind.Memory/Automind.Memory.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+ $(NoWarn);SKEXP0070;SKEXP0001
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Automind.Memory/OnnxMemoryPager.cs b/src/Automind.Memory/OnnxMemoryPager.cs
new file mode 100644
index 0000000..c9d845e
--- /dev/null
+++ b/src/Automind.Memory/OnnxMemoryPager.cs
@@ -0,0 +1,164 @@
+using Microsoft.Extensions.AI;
+using Microsoft.SemanticKernel.Connectors.Onnx;
+
+namespace Automind.Memory;
+
+/// One page of the neural computer's virtual memory: a rule summary or a document chunk.
+public sealed record MemoryChunk(string Id, string Kind, string Title, string Text)
+{
+ public const string RuleKind = "rule";
+ public const string DocKind = "doc";
+}
+
+public interface IMemoryPager : IDisposable
+{
+ Task IndexAsync(MemoryChunk chunk, CancellationToken cancellationToken = default);
+
+ Task> RecallAsync(string query, int top, CancellationToken cancellationToken = default);
+}
+
+///
+/// RAG as virtual memory, per the papers: the ENGINE (not the model) pages relevant knowledge
+/// into the context. Embeddings run fully IN-PROCESS via the Semantic Kernel BERT-ONNX connector
+/// (bge-micro-v2, ~23 MB, CPU, milliseconds) — no Ollama, no server, no generative model.
+/// Retrieval is a plain cosine top-k over an in-memory index: at POC scale (hundreds of chunks)
+/// this is exact, dependency-light, and trivially swappable for a vector database later.
+///
+public sealed class OnnxMemoryPager : IMemoryPager
+{
+ // Same source name as Automind.Reaqtor's — listeners subscribe by NAME, and this project
+ // deliberately has no substrate reference.
+ private static readonly System.Diagnostics.ActivitySource s_activity = new("Automind.Substrate");
+
+ private readonly IEmbeddingGenerator> _embeddings;
+ private readonly Lock _gate = new();
+ private readonly List<(MemoryChunk Chunk, float[] Vector)> _index = [];
+
+ private OnnxMemoryPager(IEmbeddingGenerator> embeddings) => _embeddings = embeddings;
+
+ /// Probes for model.onnx + vocab.txt; null when absent.
+ public static async Task TryCreateAsync(string modelDirectory, CancellationToken cancellationToken = default)
+ {
+ var model = Path.Combine(modelDirectory, "model.onnx");
+ var vocab = Path.Combine(modelDirectory, "vocab.txt");
+
+ if (!File.Exists(model) || !File.Exists(vocab))
+ {
+ return null;
+ }
+
+ // NB: the M.E.AI-native BertOnnxEmbeddingGenerator is INTERNAL in 1.78.0-alpha (only the
+ // DI registration reaches it); the service type is the public construction surface, so we
+ // adapt it to IEmbeddingGenerator ourselves.
+#pragma warning disable CS0618
+ var service = await BertOnnxTextEmbeddingGenerationService.CreateAsync(
+ model, vocab, new BertOnnxOptions { NormalizeEmbeddings = true }, cancellationToken).ConfigureAwait(false);
+#pragma warning restore CS0618
+
+ return new OnnxMemoryPager(new ServiceAdapter(service));
+ }
+
+ public async Task IndexAsync(MemoryChunk chunk, CancellationToken cancellationToken = default)
+ {
+ using var activity = s_activity.StartActivity("memory.index");
+ activity?.SetTag("automind.memory.chunk", chunk.Id);
+ activity?.SetTag("automind.memory.kind", chunk.Kind);
+
+ var embedding = await _embeddings.GenerateAsync(chunk.Title + "\n" + chunk.Text, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var vector = embedding.Vector.ToArray();
+
+ lock (_gate)
+ {
+ _index.RemoveAll(entry => entry.Chunk.Id == chunk.Id);
+ _index.Add((chunk, vector));
+ activity?.SetTag("automind.memory.index_size", _index.Count);
+ }
+ }
+
+ public async Task> RecallAsync(string query, int top, CancellationToken cancellationToken = default)
+ {
+ using var activity = s_activity.StartActivity("memory.recall");
+ activity?.SetTag("automind.memory.top", top);
+
+ var embedding = await _embeddings.GenerateAsync(query, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var vector = embedding.Vector.ToArray();
+
+ lock (_gate)
+ {
+ var hits = _index
+ .Select(entry => (entry.Chunk, Score: Cosine(vector, entry.Vector)))
+ .OrderByDescending(x => x.Score)
+ .Take(top)
+ .ToList();
+
+ activity?.SetTag("automind.memory.hits", hits.Count);
+ activity?.SetTag("automind.memory.best_score", hits.Count > 0 ? hits[0].Score : 0f);
+
+ return [.. hits.Select(x => x.Chunk)];
+ }
+ }
+
+ private static float Cosine(float[] a, float[] b)
+ {
+ // Vectors are normalized at embedding time; the dot product IS the cosine.
+ var sum = 0f;
+ var length = Math.Min(a.Length, b.Length);
+
+ for (var i = 0; i < length; i++)
+ {
+ sum += a[i] * b[i];
+ }
+
+ return sum;
+ }
+
+ /// Paragraph-aware document chunking (~ per page).
+ public static IEnumerable ChunkDocument(string name, string text, int maxChars = 1500)
+ {
+ var paragraphs = text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var buffer = new System.Text.StringBuilder();
+ var page = 0;
+
+ foreach (var paragraph in paragraphs)
+ {
+ if (buffer.Length > 0 && buffer.Length + paragraph.Length > maxChars)
+ {
+ yield return new MemoryChunk($"{name}#{page}", MemoryChunk.DocKind, name, buffer.ToString());
+ buffer.Clear();
+ page++;
+ }
+
+ buffer.AppendLine(paragraph).AppendLine();
+ }
+
+ if (buffer.Length > 0)
+ {
+ yield return new MemoryChunk($"{name}#{page}", MemoryChunk.DocKind, name, buffer.ToString());
+ }
+ }
+
+ public void Dispose() => _embeddings.Dispose();
+
+#pragma warning disable CS0618 // adapting the public (obsolete-flagged) service to M.E.AI
+ private sealed class ServiceAdapter : IEmbeddingGenerator>
+ {
+ private readonly BertOnnxTextEmbeddingGenerationService _service;
+
+ public ServiceAdapter(BertOnnxTextEmbeddingGenerationService service) => _service = service;
+
+ public async Task>> GenerateAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var vectors = await _service.GenerateEmbeddingsAsync([.. values], kernel: null, cancellationToken).ConfigureAwait(false);
+
+ return new GeneratedEmbeddings>([.. vectors.Select(v => new Embedding(v))]);
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) => null;
+
+ public void Dispose() => _service.Dispose();
+ }
+#pragma warning restore CS0618
+}
diff --git a/src/Automind.Reaqtor/Automind.Reaqtor.csproj b/src/Automind.Reaqtor/Automind.Reaqtor.csproj
new file mode 100644
index 0000000..da1b4b7
--- /dev/null
+++ b/src/Automind.Reaqtor/Automind.Reaqtor.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Automind.Reaqtor/Catalog/ConversationCatalog.cs b/src/Automind.Reaqtor/Catalog/ConversationCatalog.cs
new file mode 100644
index 0000000..77ddec7
--- /dev/null
+++ b/src/Automind.Reaqtor/Catalog/ConversationCatalog.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using System.Text.Json;
+
+using Reaqtor.QueryEngine;
+
+namespace Automind.Reaqtor.Catalog;
+
+public sealed record ConversationRecord(string DerivationId, string Topic, string QuestionJson, string Status)
+{
+ public const string PendingStatus = "pending";
+ public const string CompletedStatus = "completed";
+ public const string FailedStatus = "failed";
+}
+
+///
+/// Durable conversation index on the engine's own key-value store (separate table). The host
+/// reads it BEFORE RecoverAsync to pre-create egress topics — a recovered egress observer
+/// resolves its topic inside SetContext, so topics must exist before engine recovery.
+///
+public sealed class ConversationCatalog
+{
+ private const string TableName = "automind-conversations";
+
+ private readonly IKeyValueStore _store;
+
+ public ConversationCatalog(IKeyValueStore store) => _store = store;
+
+ public async Task UpsertAsync(ConversationRecord record, CancellationToken token = default)
+ {
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(record));
+
+ if (table.Contains(record.DerivationId))
+ {
+ table.Update(record.DerivationId, bytes);
+ }
+ else
+ {
+ table.Add(record.DerivationId, bytes);
+ }
+
+ await tx.CommitAsync(token).ConfigureAwait(false);
+ }
+
+ public async Task SetStatusAsync(string derivationId, string status, CancellationToken token = default)
+ {
+ var existing = All().FirstOrDefault(r => r.DerivationId == derivationId);
+
+ if (existing is not null)
+ {
+ await UpsertAsync(existing with { Status = status }, token).ConfigureAwait(false);
+ }
+ }
+
+ public IReadOnlyList All()
+ {
+ var records = new List();
+
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+
+ using var rows = table.GetEnumerator();
+
+ while (rows.MoveNext())
+ {
+ var record = JsonSerializer.Deserialize(Encoding.UTF8.GetString(rows.Current.Value));
+
+ if (record is not null)
+ {
+ records.Add(record);
+ }
+ }
+
+ return records;
+ }
+
+ public IReadOnlyList Active() =>
+ [.. All().Where(r => r.Status == ConversationRecord.PendingStatus)];
+}
diff --git a/src/Automind.Reaqtor/Catalog/DocCatalogStore.cs b/src/Automind.Reaqtor/Catalog/DocCatalogStore.cs
new file mode 100644
index 0000000..4a77bf4
--- /dev/null
+++ b/src/Automind.Reaqtor/Catalog/DocCatalogStore.cs
@@ -0,0 +1,62 @@
+using System.Text;
+using System.Text.Json;
+
+using Reaqtor.QueryEngine;
+
+namespace Automind.Reaqtor.Catalog;
+
+///
+/// Durable raw-document store (table automind-docs). Documents persist as text; the memory
+/// pager re-embeds them at startup — embeddings are local and take milliseconds, so re-indexing
+/// beats persisting vectors.
+///
+public sealed class DocCatalogStore
+{
+ private const string TableName = "automind-docs";
+
+ private readonly IKeyValueStore _store;
+
+ public DocCatalogStore(IKeyValueStore store) => _store = store;
+
+ public sealed record StoredDoc(string Name, string Text);
+
+ public async Task SaveAsync(string name, string text, CancellationToken token = default)
+ {
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new StoredDoc(name, text)));
+
+ if (table.Contains(name))
+ {
+ table.Update(name, bytes);
+ }
+ else
+ {
+ table.Add(name, bytes);
+ }
+
+ await tx.CommitAsync(token).ConfigureAwait(false);
+ }
+
+ public IReadOnlyList All()
+ {
+ var docs = new List();
+
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+
+ using var rows = table.GetEnumerator();
+
+ while (rows.MoveNext())
+ {
+ var doc = JsonSerializer.Deserialize(Encoding.UTF8.GetString(rows.Current.Value));
+
+ if (doc is not null)
+ {
+ docs.Add(doc);
+ }
+ }
+
+ return docs;
+ }
+}
diff --git a/src/Automind.Reaqtor/Catalog/McpCatalogStore.cs b/src/Automind.Reaqtor/Catalog/McpCatalogStore.cs
new file mode 100644
index 0000000..0964de1
--- /dev/null
+++ b/src/Automind.Reaqtor/Catalog/McpCatalogStore.cs
@@ -0,0 +1,51 @@
+using System.Text;
+using System.Text.Json;
+
+using Reaqtor.QueryEngine;
+
+namespace Automind.Reaqtor.Catalog;
+
+///
+/// Durable record of the MCP server commands bridged into this data directory (table
+/// automind-mcp). Recovery re-issues in-flight tool calls by URI, so a resumed process
+/// must reconnect the same servers or those calls fail "no tool is registered" — the CLI
+/// promises "kill and re-run to resume" without requiring the flags to be repeated
+/// (review finding).
+///
+public sealed class McpCatalogStore
+{
+ private const string TableName = "automind-mcp";
+ private const string Key = "servers";
+
+ private readonly IKeyValueStore _store;
+
+ public McpCatalogStore(IKeyValueStore store) => _store = store;
+
+ public async Task SaveAsync(IReadOnlyList serverCommands, CancellationToken token = default)
+ {
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(serverCommands));
+
+ if (table.Contains(Key))
+ {
+ table.Update(Key, bytes);
+ }
+ else
+ {
+ table.Add(Key, bytes);
+ }
+
+ await tx.CommitAsync(token).ConfigureAwait(false);
+ }
+
+ public IReadOnlyList Servers()
+ {
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+
+ return table.Contains(Key)
+ ? JsonSerializer.Deserialize>(Encoding.UTF8.GetString(table[Key])) ?? []
+ : [];
+ }
+}
diff --git a/src/Automind.Reaqtor/Catalog/RuleCatalogStore.cs b/src/Automind.Reaqtor/Catalog/RuleCatalogStore.cs
new file mode 100644
index 0000000..cc29625
--- /dev/null
+++ b/src/Automind.Reaqtor/Catalog/RuleCatalogStore.cs
@@ -0,0 +1,68 @@
+using System.Text;
+using System.Text.Json;
+
+using Reaqtor.QueryEngine;
+
+using Universalis.Core.Ir;
+
+namespace Automind.Reaqtor.Catalog;
+
+///
+/// Durable rule library on the engine's own key-value store. Each row carries BOTH forms of a
+/// learned rule: the executable IR (interpreted at call sites) and the Bonsai expression tree
+/// (the durable, language-agnostic intentional representation). Rows commit through the same
+/// write-through path as everything else — a learned rule survives a process kill.
+///
+public sealed class RuleCatalogStore
+{
+ private const string TableName = "automind-rules";
+
+ private readonly IKeyValueStore _store;
+
+ public RuleCatalogStore(IKeyValueStore store) => _store = store;
+
+ public sealed record StoredRule(string Name, string IrJson, string BonsaiJson)
+ {
+ public RuleDefinition Definition => Universalis.Core.Ir.IrJson.DeserializeRule(IrJson);
+ }
+
+ public async Task SaveAsync(string name, string irJson, string bonsaiJson, CancellationToken token = default)
+ {
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new StoredRule(name, irJson, bonsaiJson)));
+
+ if (table.Contains(name))
+ {
+ table.Update(name, bytes);
+ }
+ else
+ {
+ table.Add(name, bytes);
+ }
+
+ await tx.CommitAsync(token).ConfigureAwait(false);
+ }
+
+ public IReadOnlyList All()
+ {
+ var rules = new List();
+
+ using var tx = _store.CreateTransaction();
+ var table = _store.GetTable(TableName).Enter(tx);
+
+ using var rows = table.GetEnumerator();
+
+ while (rows.MoveNext())
+ {
+ var rule = JsonSerializer.Deserialize(Encoding.UTF8.GetString(rows.Current.Value));
+
+ if (rule is not null)
+ {
+ rules.Add(rule);
+ }
+ }
+
+ return rules;
+ }
+}
diff --git a/src/Automind.Reaqtor/Catalog/ToolRegistryStepContextProvider.cs b/src/Automind.Reaqtor/Catalog/ToolRegistryStepContextProvider.cs
new file mode 100644
index 0000000..37e0b74
--- /dev/null
+++ b/src/Automind.Reaqtor/Catalog/ToolRegistryStepContextProvider.cs
@@ -0,0 +1,27 @@
+using System.Collections.Immutable;
+
+using Automind.Kernel.Contract;
+using Automind.Reaqtor.Reactive;
+using Automind.Tools;
+
+using Universalis.Core.Ir;
+
+namespace Automind.Reaqtor.Catalog;
+
+///
+/// Builds the step-context snapshot from the live tool registry (and, from P7, the rule catalog).
+/// The snapshot is rebuilt per access so runtime-defined rules become visible to the next step.
+///
+public sealed class ToolRegistryStepContextProvider : IStepContextProvider
+{
+ private readonly IToolRegistry _tools;
+ private ImmutableArray _rules = [];
+
+ public ToolRegistryStepContextProvider(IToolRegistry tools) => _tools = tools;
+
+ public void AddRule(RuleDefinition rule) => _rules = _rules.Add(rule);
+
+ public StepContext Current => new(
+ [.. _tools.All.Select(t => new ToolBinding(t.Tool.Signature, t.Uri, t.Tool.IsIdempotent, t.Tool.Description))],
+ _rules);
+}
diff --git a/src/Automind.Reaqtor/Client/AutomindClientContext.cs b/src/Automind.Reaqtor/Client/AutomindClientContext.cs
new file mode 100644
index 0000000..53f1296
--- /dev/null
+++ b/src/Automind.Reaqtor/Client/AutomindClientContext.cs
@@ -0,0 +1,47 @@
+using Automind.Reaqtor.Engine;
+
+using Reaqtor;
+using Reaqtor.Shebang.Client;
+using Reaqtor.Shebang.Service;
+
+namespace Automind.Reaqtor.Client;
+
+///
+/// The strongly-typed development surface over the Automind engine — the Reaqtor analog of a
+/// LINQ-to-SQL DataContext. Each accessor binds a URI-identified engine artifact; the
+/// makes the expression rewriter turn calls into invocations
+/// of the unbound artifact parameter.
+///
+public sealed class AutomindClientContext : ClientContext
+{
+ public AutomindClientContext(IReactiveServiceProvider provider) : base(provider)
+ {
+ }
+
+ public static AutomindClientContext For(SimplerCheckpointingQueryEngine engine) =>
+ new(new LocalReactiveServiceProvider(engine.ServiceProvider));
+
+ [KnownResource(AutomindUris.TimerId)]
+ public IAsyncReactiveQbservable Heartbeat(TimeSpan period) =>
+ GetObservable(AutomindUris.Timer)(period);
+
+ [KnownResource(AutomindUris.IngressId)]
+ public IAsyncReactiveQbservable Ingress(string topic) =>
+ GetObservable(AutomindUris.Ingress)(topic);
+
+ [KnownResource(AutomindUris.EgressId)]
+ public IAsyncReactiveQbserver Egress(string topic) =>
+ GetObserver(AutomindUris.Egress)(topic);
+
+ [KnownResource(AutomindUris.ConsoleId)]
+ public IAsyncReactiveQbserver ConsoleSink() =>
+ GetObserver(AutomindUris.Console);
+
+ [KnownResource(AutomindUris.DerivationId)]
+ public IAsyncReactiveQbservable Derivation(string derivationId, string questionJson) =>
+ GetObservable(AutomindUris.Derivation)(derivationId, questionJson);
+
+ [KnownResource(AutomindUris.ToolInvokeId)]
+ public IAsyncReactiveQbservable ToolInvoke(string toolUri, string argsJson) =>
+ GetObservable(AutomindUris.ToolInvoke)(toolUri, argsJson);
+}
diff --git a/src/Automind.Reaqtor/Engine/AutomindArtifacts.cs b/src/Automind.Reaqtor/Engine/AutomindArtifacts.cs
new file mode 100644
index 0000000..93b776d
--- /dev/null
+++ b/src/Automind.Reaqtor/Engine/AutomindArtifacts.cs
@@ -0,0 +1,55 @@
+using System.Linq.CompilerServices.TypeSystem;
+using System.Linq.Expressions;
+
+using Automind.Reaqtor.Client;
+using Automind.Reaqtor.Reactive;
+
+using Reaqtor.Shebang.Linq;
+using Reaqtor.Shebang.Service;
+
+namespace Automind.Reaqtor.Engine;
+
+///
+/// Defines the Automind artifact catalog in a (fresh) engine. All definitions go through the
+/// async ServiceProvider path, which is write-ahead-logged; the factory checkpoints
+/// immediately afterwards so definitions can never be lost.
+///
+public static class AutomindArtifacts
+{
+ public static async Task DefineAsync(SimplerCheckpointingQueryEngine engine, CancellationToken token = default)
+ {
+ var ctx = AutomindClientContext.For(engine);
+
+ await ctx.DefineObservableAsync(
+ AutomindUris.Timer,
+ period => new TimerObservable(period).AsAsyncQbservable(),
+ null, token).ConfigureAwait(false);
+
+ await ctx.DefineObservableAsync(
+ AutomindUris.Ingress,
+ topic => new IngressObservable(topic).AsAsyncQbservable(),
+ null, token).ConfigureAwait(false);
+
+ await ctx.DefineObserverAsync(
+ AutomindUris.Egress,
+ topic => new EgressObserver(topic).AsAsyncQbserver(),
+ null, token).ConfigureAwait(false);
+
+ await ctx.DefineObserverAsync(
+ AutomindUris.Console,
+ ctx.Provider.CreateQbserver(Expression.New(typeof(ConsoleObserver))),
+ null, token).ConfigureAwait(false);
+
+ // The neural computer's entry point: one derivation per question, as a standing query.
+ await ctx.DefineObservableAsync(
+ AutomindUris.Derivation,
+ (derivationId, questionJson) => new DerivationObservable(derivationId, questionJson).AsAsyncQbservable(),
+ null, token).ConfigureAwait(false);
+
+ // The tool router: dynamic dispatch over the static artifact space (tool URI as data).
+ await ctx.DefineObservableAsync(
+ AutomindUris.ToolInvoke,
+ (toolUri, argsJson) => new ToolInvokeObservable(toolUri, argsJson).AsAsyncQbservable(),
+ null, token).ConfigureAwait(false);
+ }
+}
diff --git a/src/Automind.Reaqtor/Engine/AutomindEngineFactory.cs b/src/Automind.Reaqtor/Engine/AutomindEngineFactory.cs
new file mode 100644
index 0000000..bab5d81
--- /dev/null
+++ b/src/Automind.Reaqtor/Engine/AutomindEngineFactory.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+
+using Reaqtive.Scheduler;
+
+using Reaqtor.Shebang.Service;
+
+namespace Automind.Reaqtor.Engine;
+
+///
+/// Stands up (or recovers) an Automind query engine over a state store. The caller owns the
+/// (its worker threads are foreground by design — a live scheduler
+/// means in-flight work — so hosts and tests must dispose it to let the process exit); each engine
+/// gets its own , whose ownership transfers to the engine.
+///
+public static class AutomindEngineFactory
+{
+ public static async Task CreateNewAsync(
+ IQueryEngineStateStore store,
+ PhysicalScheduler scheduler,
+ IReadOnlyDictionary? services = null,
+ IIngressEgressManager? ingressEgressManager = null,
+ TraceSource? traceSource = null)
+ {
+ var engine = Create(store, scheduler, services, ingressEgressManager, traceSource);
+
+ await AutomindArtifacts.DefineAsync(engine).ConfigureAwait(false);
+
+ // Persist the definitions before accepting any traffic (KB: sync/unlogged defines that
+ // aren't followed by a checkpoint can be lost forever; ours are WAL-logged, but the
+ // bootstrap checkpoint also makes recovery of a freshly created store well-defined).
+ await engine.CheckpointAsync().ConfigureAwait(false);
+
+ return engine;
+ }
+
+ public static async Task RecoverAsync(
+ IQueryEngineStateStore store,
+ PhysicalScheduler scheduler,
+ IReadOnlyDictionary? services = null,
+ IIngressEgressManager? ingressEgressManager = null,
+ TraceSource? traceSource = null)
+ {
+ var engine = Create(store, scheduler, services, ingressEgressManager, traceSource);
+
+ await engine.RecoverAsync().ConfigureAwait(false);
+
+ return engine;
+ }
+
+ private static SimplerCheckpointingQueryEngine Create(
+ IQueryEngineStateStore store,
+ PhysicalScheduler scheduler,
+ IReadOnlyDictionary? services,
+ IIngressEgressManager? ingressEgressManager,
+ TraceSource? traceSource)
+ {
+#pragma warning disable CA2000 // Dispose objects before losing scope. (Engine takes ownership.)
+ var logicalScheduler = new LogicalScheduler(scheduler);
+#pragma warning restore CA2000
+
+ return new SimplerCheckpointingQueryEngine(
+ new Uri("automind://engine/" + Guid.NewGuid().ToString("D")),
+ logicalScheduler,
+ store,
+ services,
+ ingressEgressManager,
+ traceSource);
+ }
+}
diff --git a/src/Automind.Reaqtor/Engine/AutomindServices.cs b/src/Automind.Reaqtor/Engine/AutomindServices.cs
new file mode 100644
index 0000000..30c6b9d
--- /dev/null
+++ b/src/Automind.Reaqtor/Engine/AutomindServices.cs
@@ -0,0 +1,25 @@
+using Automind.Kernel.Contract;
+using Automind.Reaqtor.Llm;
+using Automind.Reaqtor.Reactive;
+using Automind.Tools;
+
+namespace Automind.Reaqtor.Engine;
+
+///
+/// The service set injected into every operator context — how in-engine operators reach the
+/// world outside (LLM bridge, tool registry) and the pure kernel (step function, catalogs).
+///
+public sealed record AutomindServices(
+ IStepFunction StepFunction,
+ IStepContextProvider StepContext,
+ ILlmService LlmService,
+ IToolRegistry ToolRegistry)
+{
+ public IReadOnlyDictionary ToDictionary() => new Dictionary
+ {
+ [AutomindServiceKeys.StepFunction] = StepFunction,
+ [AutomindServiceKeys.StepContext] = StepContext,
+ [AutomindServiceKeys.LlmService] = LlmService,
+ [AutomindServiceKeys.ToolRegistry] = ToolRegistry,
+ };
+}
diff --git a/src/Automind.Reaqtor/Engine/AutomindUris.cs b/src/Automind.Reaqtor/Engine/AutomindUris.cs
new file mode 100644
index 0000000..34a9acd
--- /dev/null
+++ b/src/Automind.Reaqtor/Engine/AutomindUris.cs
@@ -0,0 +1,23 @@
+namespace Automind.Reaqtor.Engine;
+
+///
+/// The URI catalog of Automind-owned engine artifacts. All Automind artifacts live under the
+/// automind:// scheme; the (borrowed) generic operator space keeps its rx:// URIs
+/// (rx://builtin/id is load-bearing and well-known to all Reaqtor components).
+///
+public static class AutomindUris
+{
+ public const string TimerId = "automind://observables/timer";
+ public const string IngressId = "automind://observables/ingress";
+ public const string EgressId = "automind://observers/egress";
+ public const string ConsoleId = "automind://observers/console";
+ public const string DerivationId = "automind://derivation";
+ public const string ToolInvokeId = "automind://tools/invoke";
+
+ public static readonly Uri Timer = new(TimerId);
+ public static readonly Uri Ingress = new(IngressId);
+ public static readonly Uri Egress = new(EgressId);
+ public static readonly Uri Console = new(ConsoleId);
+ public static readonly Uri Derivation = new(DerivationId);
+ public static readonly Uri ToolInvoke = new(ToolInvokeId);
+}
diff --git a/src/Automind.Reaqtor/IO/AutomindIngressEgressManager.cs b/src/Automind.Reaqtor/IO/AutomindIngressEgressManager.cs
new file mode 100644
index 0000000..d4fe9e1
--- /dev/null
+++ b/src/Automind.Reaqtor/IO/AutomindIngressEgressManager.cs
@@ -0,0 +1,109 @@
+// Adapted from the Reaqtor Shebang/IoT samples (MIT, .NET Foundation), with one deliberate
+// behavioral change: topics are get-or-create. The sample manager throws for unknown topics,
+// which is a recovery-order trap — a recovered EgressObserver resolves its topic inside
+// SetContext, *before* the host has a chance to re-create it.
+
+using Reaqtive;
+
+using Reaqtor.Reliable;
+using Reaqtor.Shebang.Service;
+
+namespace Automind.Reaqtor.IO;
+
+///
+/// The world outside the query engine: named reliable topics (compare to Event Hub topics).
+/// In-engine proxies ( / )
+/// discover this manager through the operator context under the key "IngressEgressManager".
+///
+public sealed class AutomindIngressEgressManager : IIngressEgressManager
+{
+ private readonly Dictionary _subjects = [];
+
+ public IReliableSubject GetOrCreateSubject(string name)
+ {
+ lock (_subjects)
+ {
+ if (_subjects.TryGetValue(name, out var existing))
+ {
+ return existing as ReliableSubject
+ ?? throw new InvalidOperationException($"Topic '{name}' already exists with element type '{existing.GetType().GenericTypeArguments[0]}', not '{typeof(T)}'.");
+ }
+
+ var subject = new ReliableSubject();
+ _subjects.Add(name, subject);
+ return subject;
+ }
+ }
+
+ public IReadOnlyList ListTopics()
+ {
+ lock (_subjects)
+ {
+ return [.. _subjects.Keys];
+ }
+ }
+
+ IReliableObserver IIngressEgressManager.GetObserver(string name)
+ {
+ // Used by EgressObserver instances inside the query engine.
+ return new ReliableObserver((ReliableSubject)GetOrCreateSubject(name));
+ }
+
+ IReliableObservable IIngressEgressManager.GetObservable(string name)
+ {
+ // Used by IngressObservable instances inside the query engine.
+ return new ReliableObservable((ReliableSubject)GetOrCreateSubject(name));
+ }
+
+ private sealed class ReliableObserver : IReliableObserver
+ {
+ private readonly ReliableSubject _subject;
+
+ public ReliableObserver(ReliableSubject subject) => _subject = subject;
+
+ public Uri ResubscribeUri => throw new NotSupportedException("Used for engine-to-engine communication; N/A here.");
+
+ public void OnCompleted() => _subject.OnCompleted();
+
+ public void OnError(Exception error) => _subject.OnError(error);
+
+ public void OnNext(T item, long sequenceId) => _subject.OnNext((sequenceId, item));
+
+ public void OnStarted() { }
+ }
+
+ private sealed class ReliableObservable : IReliableObservable
+ {
+ private readonly ReliableSubject _subject;
+
+ public ReliableObservable(ReliableSubject