digest-aware serialized extraction + chain simplification replace the spine stage

Owner live-test verdict on the spine (2026-09-30): no perceptible gain —
the real problems (restatement nodes, shallow re-derivations, same
conclusion from many angles) are mint-time issues a post-hoc chain
minter cannot fix. This arc moves assembly INTO the extractor and adds
the deterministic dedupe the owner specified:

- Digest-aware extraction: each stretch's extractor prompt carries THE
  GRAPH SO FAR (its own source's merged propositions + chains, ids
  verbatim); build on existing ids instead of restating, lean on
  established conclusions as premises. Mixed-namespace chain endpoints
  (fresh p-ids OR existing n_-ids) validated at the submit seam.
- Serialized per source: a source's batches/chunks run strictly in
  order (each extract reads every earlier merge); cross-source runs
  stay parallel; source-wide abort stops the loop. The spine stage,
  role, phase, usage stage, cost row, and setup roster are gone.
- Core-ideas prompt: goal reframed as uncovering core ideas and showing
  how everything derives from the foundations; direction upward, prefer
  lean derivations, never re-derive what the graph already holds.
- Chain simplification (merger maintenance): simplifyChains drops
  provably redundant chains at every merge — prefer-fewest-premises +
  ancestry-cover ([A,B]⟹X with [X,C]⟹Y covers [A,B,C]⟹Y, transitive);
  same-key provenance lines drop together; rewriteLedgers under the
  audit lock; droppedChains counted separately in stats.

Chunk budget stays 8192 (owner: keep for now, maybe lower later).
This commit is contained in:
amosblomqvist
2026-09-30 17:44:37 +03:00
parent 52c02533d1
commit 91341a93da
18 changed files with 734 additions and 879 deletions
+4 -4
View File
@@ -68,7 +68,7 @@ flowchart TB
end
subgraph LIBRARY["LIBRARY — world model (owns data/graph ledgers)"]
EXTRACT["extraction pipeline:<br/>extractor → spine → auditor → merger<br/>(the SOLE graph writer)"]
EXTRACT["extraction pipeline:<br/>extractor → auditor → merger<br/>(the SOLE graph writer, serial per source)"]
DERIVE["derivation — graphSnapshot · birth-session clusters<br/>(pure reads)"]
LEDG[("append-only ledgers<br/>data/graph/*.jsonl")]
end
@@ -93,7 +93,7 @@ flowchart TB
HOST -.->|"hosts the server side"| TURN
```
**Core invariant:** the extraction pipeline (extractor → spine → auditor → merger) is the sole graph writer — tree-blind and post-hoc, triggered by token coverage, not quiz counts. The LLM stages propose, verify, and judge identity (link-or-create against the current graph; each finding carries verbatim evidence and message-granular provenance); the deterministic merger owns every ledger append (ids, provenance, chain dedupe — the same argument re-asserted merges under one `arg_` id). Every node is an objective, world-facing fact; the learner's relationship to it lives entirely in practice records — feedback-only, never written back. The teacher stays a teacher; everything below the ledgers is derived.
**Core invariant:** the extraction pipeline (extractor → auditor → merger) is the sole graph writer — tree-blind and post-hoc, triggered by token coverage, not quiz counts. The LLM stages propose, verify, and judge identity (link-or-create against the current graph; each finding carries verbatim evidence and message-granular provenance); the deterministic merger owns every ledger append (ids, provenance, chain dedupe — the same argument re-asserted merges under one `arg_` id). Every node is an objective, world-facing fact; the learner's relationship to it lives entirely in practice records — feedback-only, never written back. The teacher stays a teacher; everything below the ledgers is derived.
## Layout
@@ -114,7 +114,7 @@ src/
tools/web.ts web_search (Google CSE) + web_fetch (researcher only)
subagents/ researcher · demo-builder · visual-maker
· pdf-reader,
extraction pipeline (extractor/spine/auditor + merger handoff),
extraction pipeline (extractor/auditor + merger handoff),
runner (async protocol), headless Chrome helper,
abort registry
server/chat-handler.ts THE turn composition root (liveness · steers · persist · stream)
@@ -154,7 +154,7 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
**The async sub-agent protocol (ack-and-steer).** `research` / `demo` / `visual` are server-executed tools: the call resolves immediately mid-turn with a fire-and-forget ack and the teacher keeps teaching; a background runner works in parallel (the client polls `/api/activity` and `/api/jobs`). On completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client — and the **wake pipeline** delivers it: a per-chat serialized queue waits for the turn to end and no question to be pending, then fires a server-side teacher turn that weaves the result into the lesson. Delivery is exactly-once (deterministic steer ids, delivered-flag checks at every claim point); stalled jobs are recovered on read; aborted jobs deliver an abort steer. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate) and renders steers as artifacts — never a bubble. Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (vision-capable model — the `vision` slot in settings).
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the source — the session's teacher prose or a notes chunk — fully trusted, graph-blind; its deliverable is propositions + reasoning chains: premises that jointly entail a conclusion, submitted as chains and stored as chains — `chains.jsonl` is the truth, edges a derived view; owner redesign + chains-primary, 2026-09-30) → spine (LLM, strong tier: sees the same-source digest + the fresh stretch, never the source; mints only what spans them — cross-stretch chains + connective propositions, span law enforced at the submit seam; owner stage, 2026-09-30) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools and stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes chains — identical arguments merge under one `arg_` id — checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 8192 since the spine stage, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read (`deriveView` rebuilds the lossy image: each chain an implication node `⟹` with premise→implication→conclusion arrows, so two arguments for one conclusion stay two diamonds); the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees the source — the session's teacher prose or a notes chunk — plus its own source's digest (**THE GRAPH SO FAR**: propositions + chains already extracted from this source, ids verbatim — digest-aware redesign, 2026-09-30 evening: the extractor IS the assembler, referencing existing ids instead of restating, leaning on established conclusions as premises); fully trusted; its deliverable is core ideas + reasoning chains: premises that jointly entail a conclusion, submitted as chains and stored as chains — `chains.jsonl` is the truth, edges a derived view; owner redesign + chains-primary, 2026-09-30) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools and stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes chains — identical arguments merge under one `arg_` id; then the chain-simplification pass strips provably redundant derivations — prefer-fewest-premises + ancestry-cover — checkpoints coverage) → layout settle. A source's batches run strictly in order (each extractor continues the digest of every earlier merge). No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 8192, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read (`deriveView` rebuilds the lossy image: each chain an implication node `⟹` with premise→implication→conclusion arrows, so two arguments for one conclusion stay two diamonds); the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
+4 -8
View File
@@ -309,14 +309,11 @@ export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
totalDollars: number;
unpricedRuns: number;
} {
// The three live stages (spine joined 2026-09-30); empty spines
// contribute zero — the fold leaves them at 0 when a run never
// called the spine (first-stretch skip / pre-spine records).
// The two live stages (the spine came and went on one day,
// 2026-09-30 — digest-aware extraction replaced it).
return {
totalDollars:
costs.stages.extraction.dollars + costs.stages.spine.dollars + costs.stages.audit.dollars,
unpricedRuns:
costs.stages.extraction.unpricedRuns + costs.stages.spine.unpricedRuns + costs.stages.audit.unpricedRuns,
totalDollars: costs.stages.extraction.dollars + costs.stages.audit.dollars,
unpricedRuns: costs.stages.extraction.unpricedRuns + costs.stages.audit.unpricedRuns,
};
}
@@ -373,7 +370,6 @@ function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
["Extraction", costs.stages.extraction],
["Assembly", costs.stages.spine],
["Audit", costs.stages.audit],
];
// The displayed figures come from displayCostTotals (naming excluded —
+1 -1
View File
@@ -49,7 +49,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
/** The pickers' slot identity — same roster language as the Agent
* section's slot rows. */
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
strong: { label: "Strong", roster: "Prober, teacher, spine, practice trio" },
strong: { label: "Strong", roster: "Prober, teacher, practice trio" },
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor" },
vision: { label: "Vision", roster: "Visual maker + PDF reader" },
};
-1
View File
@@ -53,7 +53,6 @@ export type ModelRole =
| "visualMaker"
| "pdfReader"
| "extractor"
| "spine"
| "auditor"
| "partitioner"
| "problemGenerator"
+8 -6
View File
@@ -239,7 +239,7 @@ flowchart TB
end
subgraph LIBRARY["LIBRARY — world model (owns data/graph ledgers)"]
EXTRACT["extraction pipeline — extractor → spine → auditor → merger (SOLE graph writer)"]
EXTRACT["extraction pipeline — extractor → auditor → merger (SOLE graph writer, serial per source)"]
DERIVE["derivation — graphSnapshot · birth-session clusters (reads, never write)"]
GRAPHDATA["append-only ledgers — data/graph/*.jsonl"]
end
@@ -350,15 +350,17 @@ flowchart TB
**Raw reasoning chains only** (2026-09-27 cleanup; teacher-prose view 2026-09-28): the extractor reads ONLY the session — and since the 2026-09-28 ruling, ONLY the teacher agent's prose: plain paragraphs under the session title, no learner messages, no thinking, no tool calls or results, no quiz blocks. Coverage fingerprints, slot tokens, and batch budgets all derive from that same selection (`messageViewLines` in `core/coverage.ts`), so "covered" is exactly "what the extractor saw" — a quiz answer or learner edit can never open a run, and only a teacher-prose edit re-opens its message. The old call+result inseparability bond is deleted (nothing left to keep together); batching is pure greedy fill. The shared markdown engine (`core/session-markdown.ts`) keeps the full session format for the future user-facing export and is no longer used by extraction. The extractor's deliverable is propositions + REASONING CHAINS (owner redesign, 2026-09-30): a chain is premises that JOINTLY entail a conclusion — independent arguments for the same conclusion are separate chains, conclusions are reusable by id as premises, and a bare proposition rides the propositions list alone. The wire schema carries propositions (id/name/statement/optional aliases) + chains (premises minItems 1/conclusion) — and since the chains-primary ruling (2026-09-30) the STORE carries exactly that wire shape: chains append as-is to `chains.jsonl` (`SessionFindings` IS the store shape now; `chainsToEdges` and `chainsToGraph` are deleted — no translation seam exists). **Supports edges are retired entirely** (owner, same evening): `EdgeType`, the supports draw toggle and thickness slider, and every supports code path are gone — a stored typed edge no longer exists anywhere, and every arrow on the canvas is derived from a chain. No migration, no backcompat: the pre-chains graph data was wiped by ruling. The chain laws are enforced by the practice-proven error-back hook at the submit seam: every premise/conclusion id must resolve to a submitted proposition, premises are non-empty, no self-reference — a violation returns an error string and the model re-submits within its turns. The prompt is GOAL-FIRST, no rule armor (owner philosophy ruling, 2026-09-30): one shared `EXTRACTION_CORE` (the goal, the truth law, the submission mechanics) plus two one-sentence contextualizations — session and notes preambles — assembled at the call sites, so both front doors share the core by construction; the old defensive lists (DROP lists, merge-your-own-output, name walls, when-in-doubt-omit, the faithfulness paragraph) are all cut, and the old opening describing Quiz/Recall-check/Question blocks (stale since the teacher-prose ruling) is simply omitted. A `prepareArguments` heal at the pre-validation seam rescues stringified-payload submits (propositions or chains). Every node is an objective, world-facing fact. No quiz outcomes, no understanding assessments — the events ledger and `UnderstandingLevel` are deleted; the learner's relationship to a proposition is practice territory — which landed 2026-09-29 as **feedback-only strengthening** (§7): grades live on problem sets, and nothing ever writes learner states back to the graph.
**The spine — cross-stretch assembly** (owner-approved stage, 2026-09-30): extraction walks a source in stretch-blind batches, and the live corpus showed the seam — 0 of 133 chains crossed a stretch boundary, because no stage minted cross-stretch chains (the extractor sees one batch, the auditor links propositions only, the merger is deterministic). The spine closes it: after each batch's extract and INSIDE the same serialized audit section (owner-specified order — extract → spine → audit → merge, so the spine reads the graph exactly as the following audit will), one strong-tier call sees the same-source digest (every merged proposition of this source, ids verbatim, plus the chains already recorded) and the fresh stretch's findings, and submits only what SPANS them: cross-stretch chains and the connective propositions a chain needs that no stretch stated on its own. The span law is mechanical, enforced at the submit seam (error-back like every chain law): every chain must touch ≥1 fresh endpoint and ≥1 existing digest endpoint — all-fresh is the stretch extractor's own work, all-existing already had its chance when the later endpoint arrived. Identity stays the auditor's lane: the spine submits no links; its connective propositions ride into the audit as ordinary findings (propositions only — the auditor still never sees chains), and its chains resolve through the merger's job-local map, which now accepts existing graph ids verbatim as endpoints (the mixed namespace: fresh `p`/`s` local ids OR digest `n_` ids). Session and notes front doors share the stage exactly as they share `EXTRACTION_CORE` — only the preamble differs. The first stretch of a source skips the call entirely (empty digest — nothing to join, no spend). The spine bills as its own usage stage (`spine` in the run record and the cost panel's Assembly row) and runs under its own phase (`assembling`).
**Digest-aware serialized extraction — the extractor is the assembler** (owner redesign, 2026-09-30 evening): the spine stage came and went in one day — the owner's live test showed the post-hoc assembly pass added complexity without perceptible gain, because the real problems (restatement nodes, shallow re-derivations) are mint-time issues. The replacement moves assembly INTO the extractor: when a source already yielded material, each stretch's extractor prompt carries **THE GRAPH SO FAR** (that source's merged propositions and chains, ids verbatim), and the extractor builds on it — referencing existing ids instead of restating them, leaning on established conclusions as premises. Chains may therefore span stretches: endpoints are a mixed namespace of fresh local ids OR existing `n_` ids (validated at the submit seam, error-back; the merger's job-local map passes existing ids through verbatim). The system prompt reframes the goal as uncovering the source's **core ideas** and showing how everything **derives from the foundations** — direction upward, prefer lean derivations, never re-derive what the graph already holds. Because each stretch must read every earlier merge, a source's batches/chunks run **STRICTLY IN ORDER** (serialized per source); cross-source runs stay parallel. The first stretch of a source runs bare (no digest, no extra spend).
**Serialized audit, parallel extracts — per-chunk jobs** (owner ruling 2026-09-28): every chunk (notes file) and every batch (chat coverage) is its own JOB — registry row, usage record, failure domain. Extracts fan out in parallel across all jobs and sources, throttled by a process-wide 3-permit extract semaphore on `globalThis` (the provider-facing rate stance; a job waiting for a permit shows `queued`). The audit→merge section remains a process-wide FIFO critical section — a mutex on `globalThis` (the same module-instance idiom as the run registries): before any auditor LLM call or graph read, a job waits (`waiting` phase) until every earlier job's merge has landed, so each auditor explores the graph as it actually stands — two concurrent audits would each link into a graph the other is about to change, the structural mint of duplicate nodes. Failure isolation: one job failing (bad chunk, degraded model output) is absorbed at the fan-out — its messages stay uncovered and honestly re-runnable while its siblings still merge; the run returns what actually merged. Abort is source-wide: every job of a source's fan-out dies together, and merged jobs keep their checkpoints. The status feed shows one row per job (`file.md · 2/5` suffixes for chunked sources), and each job persists its own usage record (failed jobs bill their partial spend too).
**Chain simplification — the merger's maintenance pass** (owner, 2026-09-30 evening): the same conclusion often arrives from many angles — `[A,B]⟹X`, `[X,C]⟹Y`, and then `[A,B,C]⟹Y`, which adds nothing. At every merge, `simplifyChains` (core/graph.ts) walks the WHOLE chain ledger and drops provably redundant chains: per conclusion group, greedy in (premise count, creation time) order, a chain is redundant when a kept chain's premises strictly contain its premise set (**prefer the fewest premises**) or when every premise is covered by a kept chain's premises plus their transitive ancestry (**ancestry-cover** — `[A,B]⟹X` + `[X,C]⟹Y` covers `[A,B,C]⟹Y`, and transitively through deeper derivations). Same-(premises, conclusion) lines are one logical chain — a key drops with every provenance line. The rewrite rides the merge's audit-locked critical section (atomic `rewriteLedgers`); run stats count the drop separately (`droppedChains`) so appends and maintenance never blur. Simplification runs at merge time because a NEW chain can make an OLD one redundant — nothing is retroactively re-derived, only provable redundancy is removed.
**Division of trust** (the pipeline's core rule): the extractor is fully trusted and is the only stage that ever sees the source. The spine sees the graph digest + the fresh findings — never the source — and mints structure only (spanning chains + connectives; no identity verdicts). The auditor never sees the transcript — it receives the findings' PROPOSITIONS ONLY (the chunk's propositions plus the spine's connectives; the chains never reach the auditor: they are the extractor→spine→store deliverable and the merger's business — owner ruling, 2026-09-30) and explores the existing graph through read-only tools (`find`, `list_sessions`, `list_propositions` — statement text included, no timestamps) to reach its verdicts. VERDICTS-ONLY (owner ruling 2026-09-28): the auditor's submit is `{ links }` — identity links only (which findings propositions ARE existing nodes). The edges argument is GONE (owner, same evening): the auditor never creates relations between propositions — cross-source connection happens solely through identified equivalences, and argument structure comes from the extractor and the spine alone. It never restates the findings; the merger joins findings + verdicts, so an order of magnitude less is emitted and there is no structural way for the auditor to alter or drop extractor output. Equivalence bias (owner ruling 2026-09-27): paraphrases, rewordings, and compressions of the same claim MERGE; only genuinely distinct propositions stay apart. The deterministic merger validates and appends: link resolution, chain resolution through the job-local map (which passes existing `n_` ids through verbatim for the spine's mixed-namespace chains), chain dedupe by (sorted premises, conclusion) — a known argument re-asserted by another chat appends under the SAME `arg_` id (link-style: the first line's id and createdAt stand, provenance unions) — coverage checkpoints. There is no drop channel — nothing rejects the extractor's output. **Per-job in-memory resolution** (owner, 2026-09-29): the mappings ledger is deleted — each job resolves its own proposition ids against the graph in memory and merges independently, so no job can see or collide with another's ids (the persisted table once silently dropped same-id propositions across chunks and welded wrong edges). Cross-job and cross-run identity is the auditor's links alone, plus the coverage ledger (nothing re-extracts) and the notes content-fingerprint guard.
**Per-chunk jobs, serial per source** (owner rulings 2026-09-28 + 2026-09-30 evening): every chunk (notes file) and every batch (chat coverage) is its own JOB — registry row, usage record, failure domain — but a source's jobs run STRICTLY IN ORDER (each extractor continues the source's digest; see above). Cross-source extracts stay parallel, throttled by a process-wide 3-permit extract semaphore on `globalThis` (the provider-facing rate stance; a job waiting for a permit shows `queued`). The audit→merge section remains a process-wide FIFO critical section — a mutex on `globalThis` (the same module-instance idiom as the run registries): before any auditor LLM call or graph read, a job waits (`waiting` phase) until every earlier job's merge has landed, so each auditor explores the graph as it actually stands — two concurrent audits would each link into a graph the other is about to change, the structural mint of duplicate nodes. Failure isolation: one job failing (bad chunk, degraded model output) is absorbed — its messages stay uncovered and honestly re-runnable while its siblings still merge; the run returns what actually merged. Abort is source-wide: the serial loop stops at the first AbortError, and merged jobs keep their checkpoints. The status feed shows one row per job (`file.md · 2/5` suffixes for chunked sources), and each job persists its own usage record (failed jobs bill their partial spend too).
**Coverage, not quiz-counting** (supersedes the v1 every-N-quiz-answers trigger): each message's *view* (the compact line format `core/coverage.ts` has always hashed) is hashed into a per-chat fingerprint ledger (`coverage.jsonl`); "covered" means exactly "a run committed on this content." The prompt view is the richer markdown document — a deliberate 2026-09-27 split: re-formatting presentation never re-opens covered history. Uncovered = fingerprint diff, never dual-tracked; runs are greedy token-budgeted batches (`graph.extractionChunkTokens` — a settings knob, default 8192 since the spine stage doubled it 2026-09-30, floor 500, read fresh each turn) of the oldest uncovered messages — quiz-less sessions extract too. A coarse `sessions.jsonl` checkpoint still marks each merged run. The trigger seam (`turn-service.ts`) is trigger-agnostic from the pipeline's side: when a better mechanism lands (session end, idle, explicit flush), only the seam changes. Single-flight per source; the run registry powers the coverage UI and true abort.
**Division of trust** (the pipeline's core rule): the extractor is fully trusted and is the only stage that ever sees the source. The auditor never sees the transcript — it receives the findings' PROPOSITIONS ONLY (the chains never reach the auditor: they are the extractor→store deliverable and the merger's business — owner ruling, 2026-09-30) and explores the existing graph through read-only tools (`find`, `list_sessions`, `list_propositions` — statement text included, no timestamps) to reach its verdicts. VERDICTS-ONLY (owner ruling 2026-09-28): the auditor's submit is `{ links }` — identity links only (which findings propositions ARE existing nodes). The edges argument is GONE (owner, same evening): the auditor never creates relations between propositions — cross-source connection happens solely through identified equivalences, and argument structure comes from the extractor alone. It never restates the findings; the merger joins findings + verdicts, so an order of magnitude less is emitted and there is no structural way for the auditor to alter or drop extractor output. Equivalence bias (owner ruling 2026-09-27): paraphrases, rewordings, and compressions of the same claim MERGE; only genuinely distinct propositions stay apart. The deterministic merger validates and appends: link resolution, chain resolution through the job-local map (which passes existing `n_` ids through verbatim for the digest-aware extractor's mixed-namespace chains), chain dedupe by (sorted premises, conclusion) — a known argument re-asserted by another chat appends under the SAME `arg_` id (link-style: the first line's id and createdAt stand, provenance unions) — the chain-simplification maintenance pass (below) — coverage checkpoints. No stage rejects the extractor's output; simplification only removes provable redundancy. **Per-job in-memory resolution** (owner, 2026-09-29): the mappings ledger is deleted — each job resolves its own proposition ids against the graph in memory and merges independently, so no job can see or collide with another's ids (the persisted table once silently dropped same-id propositions across chunks and welded wrong edges). Cross-job and cross-run identity is the auditor's links alone, plus the coverage ledger (nothing re-extracts) and the notes content-fingerprint guard.
**Two front doors, one pipeline**: chat transcripts (quiz-triggered) and raw personal notes (`POST /api/graph/import` — each file becomes a `NotesSource` playing the `chatId` role, deterministic id from the path hash; a content-fingerprint stamp makes an unchanged re-import a TRUE no-op — no model calls, no usage — while changed text re-runs the whole file, owner 2026-09-29). Notes chunk like chats (owner, 2026-09-28): large files split at line boundaries under the same token budget, and each chunk/batch fans out as its own job exactly like a chat run — parallel extracts under the semaphore, serialized audits under the gate. No single model call carries a whole file. Both submit tools require their top-level fields (`chains`/`links` present even when empty): a payload missing them fails validation rather than silently merging structure-less nodes.
**Coverage, not quiz-counting** (supersedes the v1 every-N-quiz-answers trigger): each message's *view* (the compact line format `core/coverage.ts` has always hashed) is hashed into a per-chat fingerprint ledger (`coverage.jsonl`); "covered" means exactly "a run committed on this content." The prompt view is the richer markdown document — a deliberate 2026-09-27 split: re-formatting presentation never re-opens covered history. Uncovered = fingerprint diff, never dual-tracked; runs are greedy token-budgeted batches (`graph.extractionChunkTokens` — a settings knob, default 8192 since the digest-aware redesign doubled it 2026-09-30, floor 500, read fresh each turn) of the oldest uncovered messages — quiz-less sessions extract too. A coarse `sessions.jsonl` checkpoint still marks each merged run. The trigger seam (`turn-service.ts`) is trigger-agnostic from the pipeline's side: when a better mechanism lands (session end, idle, explicit flush), only the seam changes. Single-flight per source; the run registry powers the coverage UI and true abort.
**Two front doors, one pipeline**: chat transcripts (quiz-triggered) and raw personal notes (`POST /api/graph/import` — each file becomes a `NotesSource` playing the `chatId` role, deterministic id from the path hash; a content-fingerprint stamp makes an unchanged re-import a TRUE no-op — no model calls, no usage — while changed text re-runs the whole file, owner 2026-09-29). Notes chunk like chats (owner, 2026-09-28): large files split at line boundaries under the same token budget, and each chunk runs as its own job exactly like a chat batch — serial per source (digest order), serialized audits under the gate. No single model call carries a whole file. Both submit tools require their top-level fields (`chains`/`links` present even when empty): a payload missing them fails validation rather than silently merging structure-less nodes.
**The read channel is pure derivation.** `GET /api/graph` reads the ledgers — the home view never triggers a write. **Chains-primary (owner ruling, 2026-09-30): the stored truth is propositions (`nodes.jsonl`) + reasoning chains (`chains.jsonl`); the canvas renders a LOSSY DERIVED IMAGE of what is truly stored.** `graphSnapshot` merges the ledgers, then `deriveView` (core/graph.ts) rebuilds the view on every read: each stored chain mints an IMPLICATION NODE (id = the chain's `arg_` id, name `⟹`, statement = the chain line) plus untyped arrows premise→implication→conclusion; dangling chains (a member node since deleted) drop. This is the BIJECTION the edge store could not hold: two independent arguments for one conclusion (`A∧B⇒E`, `C∧D⇒E`) render as two distinct diamonds instead of a manufactured `A∧B∧C∧D⇒E` fan-in — one stored argument, one visual argument. Implication nodes are regular physics citizens for now (rendering specialization is deliberately future work). Clusters are the BIRTH-SESSION partition over propositions only (`birthSessionPartition`, core/session-clusters.ts — session provenance; no Louvain anywhere, whatever old comments claimed) — implication nodes are never partitioned and ride the physics unclustered. Node detail cards show name + proposition + birth sessions; the edge lists died with typed edges (owner, 2026-09-30).
+285 -232
View File
@@ -23,7 +23,7 @@ import type {
ExtractionStageUsage,
SessionFindings,
} from "../../core/contracts.ts";
import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
import { getGraph, mergeFindings, type DerivedGraph, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
import type { GraphStore } from "../../core/ports.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
@@ -31,38 +31,43 @@ import { teacherProseBlocks } from "../../core/coverage.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
import {
SPINE_INSTRUCTIONS,
NOTES_SPINE_INSTRUCTIONS,
SPINE_EMPTY,
buildSpinePrompt,
combineFindings,
runSpine,
spineHasDigest,
type SpineSubmit,
} from "./spine.ts";
/**
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign;
* relinked 2026-09-27) — the sole graph writer. Three stages, one
* orchestrator:
* orchestrator (the spine stage came and went, 2026-09-30: serialized
* digest-aware extraction replaced it — the owner's live test showed
* the post-hoc assembly pass added complexity without perceptible
* gain; the extractor itself is the assembler now):
*
* extractor (LLM) the ONE stage that sees the raw source (a session
* rendered as the teacher's prose, or a notes file
* verbatim) → submits propositions + REASONING CHAINS
* (owner redesign, 2026-09-30), expanded at the seam to
* SessionFindings' follows-edges. TREE-BLIND,
* graph-blind, FULLY TRUSTED — nothing downstream
* re-checks it.
* verbatim, walked in stretches) → submits NEW core
* ideas + reasoning chains (owner redesign,
* 2026-09-30). DIGEST-AWARE (2026-09-30 evening):
* when the source already yielded material, the
* prompt carries THE GRAPH SO FAR (that source's
* propositions + chains, ids verbatim) and the
* extractor builds on it — referencing existing ids
* instead of restating, leaning on established
* conclusions as premises. Stretches of one source
* run SERIALLY (each extract reads every earlier
* merge); tree-blind, FULLY TRUSTED — nothing
* downstream re-checks it.
* auditor (LLM) never sees the source. Its job is IDENTITY: for each
* proposition, linkTo an existing graph node or create —
* decided by EXPLORING the graph through tools (find /
* list_sessions / list_propositions), not by an injected
* graph dump (context bloat, owner 2026-09-27). Links are
* its ONLY deliverable — it never mints edges (owner
* 2026-09-28: the graph connects across sources solely
* through identified equivalences).
* merger (core) mergeFindings() → deterministic ledger appends
* NEW proposition, linkTo an existing graph node or
* create — decided by EXPLORING the graph through
* tools (find / list_sessions / list_propositions),
* not by an injected graph dump (context bloat,
* owner 2026-09-27). Links are its ONLY deliverable
* — it never mints edges (owner 2026-09-28: the
* graph connects across sources solely through
* identified equivalences). Input is PROPOSITIONS
* only — never chains (owner 2026-09-30).
* merger (core) mergeFindings() → deterministic ledger appends +
* chain simplification (2026-09-30 evening: the
* ancestry-cover / prefer-fewest-premises pass that
* strips redundant derivations — core/graph.ts)
*
* The events ledger is GONE (2026-09-27): quiz outcomes and understanding
* observations are no longer formalized in the graph — the graph is purely
@@ -126,7 +131,7 @@ function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
// ─── the extractor ──────────────────────────────────────────────────────────
const propositionProps = {
id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by chains.' }),
id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by chains in this call.' }),
name: Type.String({
description:
"A very short label — established terminology, the term the discipline itself uses, a glossary " +
@@ -135,7 +140,7 @@ const propositionProps = {
}),
statement: Type.String({
description:
"One true, self-contained, world-facing reasoning step at full precision. Never about the " +
"One true, self-contained, world-facing core idea at full precision. Never about the " +
'learner, the teacher, or the reading: not "the learner understands X" but "X". A misconception ' +
"enters as its TRUE correction.",
}),
@@ -150,13 +155,20 @@ const propositionProps = {
// REASONING CHAINS — premises + conclusion — stored AS-IS (chains-primary,
// same evening: chains.jsonl is the truth; edges are a derived view, and
// supports edges are retired entirely).
const endpoint = (role: string) =>
Type.String({
description:
`The id this ${role} — EITHER an existing graph idea id from THE GRAPH SO FAR in the prompt, ` +
'verbatim ("n_…"), OR the local id of a proposition in this submission ("p1", …).',
});
const chainSchema = Type.Object({
premises: Type.Array(Type.String(), {
premises: Type.Array(endpoint("premise uses"), {
minItems: 1,
description:
"Proposition ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
"Premise ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
}),
conclusion: Type.String({ description: "The proposition id the premises jointly imply." }),
conclusion: endpoint("conclusion is"),
});
// Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` /
@@ -166,13 +178,15 @@ const chainSchema = Type.Object({
// no zod left in the agent band.
const findingsProps = {
propositions: Type.Array(Type.Object(propositionProps), {
description: "The propositions themselves — every distinct proposition the source's reasoning uses.",
description:
"Only the NEW core ideas this stretch contributes — ideas the prompt's THE GRAPH SO FAR already " +
"holds are referenced by id in chains, never restated here.",
}),
chains: Type.Array(chainSchema, {
description:
"Every argument the source makes; conclusions reusable by id as premises of later chains; " +
"bare propositions (no argument) ride the propositions list alone; pass [] when none — " +
"never omit the field.",
"Every argument this stretch makes — including arguments that join new ideas to established " +
"ones; conclusions reusable by id as premises of later chains; bare ideas (no argument) ride " +
"the propositions list alone; pass [] when none — never omit the field.",
}),
};
@@ -226,21 +240,24 @@ function fillFindings(raw: FindingsSubmit): FindingsSubmit {
};
}
/** The chain laws (extractor redesign, 2026-09-30) — the LOUD channel:
* runSubmitAgent turns a problem string into an error tool result and
* the model re-submits within its turns (the practice-proven hook).
* Identical (premise-set, conclusion) chains are NOT errors — they
* dedupe silently at the merge (chainKey): restating an argument is a
* no-op. */
function validateFindingsChains(value: FindingsSubmit): string | null {
/** The chain laws (extractor redesign, 2026-09-30; digest-aware
* 2026-09-30 evening) — the LOUD channel: runSubmitAgent turns a
* problem string into an error tool result and the model re-submits
* within its turns (the practice-proven hook). The MIXED NAMESPACE: an
* endpoint resolves when it is a submitted proposition id OR an
* existing graph node id (the extractor continues a partly-extracted
* source and references digest ids verbatim). Identical (premise-set,
* conclusion) chains are NOT errors — they dedupe silently at the
* merge (chainKey): restating an argument is a no-op. */
function validateFindingsChains(value: FindingsSubmit, existingIds: ReadonlySet<string>): string | null {
const ids = new Set(value.propositions.map((p) => p.id));
for (const chain of value.chains) {
if (chain.premises.length === 0) {
return "chains: premises must name at least one proposition — a proposition with no argument stays in the propositions list alone.";
}
const unknown = [...chain.premises, chain.conclusion].find((id) => !ids.has(id));
const unknown = [...chain.premises, chain.conclusion].find((id) => !ids.has(id) && !existingIds.has(id));
if (unknown !== undefined) {
return `chains: "${unknown}" is not one of the submitted proposition ids — every premise and conclusion must appear in this call's propositions.`;
return `chains: "${unknown}" is neither a submitted proposition id nor an existing idea id from THE GRAPH SO FAR — every premise and conclusion must resolve.`;
}
if (chain.premises.includes(chain.conclusion)) {
return "chains: a conclusion cannot be its own premise.";
@@ -277,59 +294,98 @@ function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts {
// 2026-09-28 teacher-prose ruling and is simply omitted.)
export const SESSION_PREAMBLE =
"You are the extraction stage of a learning application. You read one source — a teaching session, " +
"rendered as the teacher's prose in reading order — and report what it teaches as findings for the " +
"learner's global knowledge graph.";
"You are the extraction stage of a learning application. You read one stretch of a source — a teaching " +
"session, rendered as the teacher's prose in reading order — and report what it teaches as findings " +
"for the learner's global knowledge graph, building on everything already extracted from this source.";
export const NOTES_PREAMBLE =
"You are the extraction stage of a learning application. You read one source — one chunk of the " +
"You are the extraction stage of a learning application. You read one stretch of a source — the " +
"author's personal notes, rendered in reading order — and report what it teaches as findings for the " +
"learner's global knowledge graph.";
"learner's global knowledge graph, building on everything already extracted from this source.";
export const EXTRACTION_CORE = `THE GOAL: extract the source's REASONING CHAINS. The source teaches by making arguments — propositions
worked out from other propositions. Your deliverable is twofold: the propositions themselves, and every
argument the source makes. Each argument is a chain: premises that JOINTLY entail a conclusion.
Entailment is the only link that earns a chain — where the real connection is historical or narrative
(who argued what, how a debate or exposition unfolds), there is no chain; keep the pieces as bare
propositions or drop them.
export const EXTRACTION_CORE = `THE GOAL: uncover the source's CORE IDEAS and show how everything derives from the foundations. The
source teaches by arguing — ideas worked out from other ideas, down to a foundation the rest stands on.
Your deliverable is twofold: the core ideas themselves, and the chains of reasoning that derive them.
Each chain is premises that JOINTLY entail a conclusion. Entailment is the only link that earns a
chain — where the real connection is historical or narrative (who argued what, how a debate or
exposition unfolds), there is no chain; keep the pieces as bare ideas or drop them. The direction of
the graph is upward: foundational ideas at the bottom, derived ideas above them, each chain showing
how its conclusion follows from what is more established — do not submit connections that carry no
derivation.
The graph holds TRUE propositions about the world, nothing else — never anything about the learner, the
The graph holds TRUE ideas about the world, nothing else — never anything about the learner, the
teacher, or this reading: not "the learner understands X" but X. A misconception enters as its TRUE
correction. Each statement is one self-contained reasoning step at full precision.
correction. Each statement is one self-contained idea at full precision.
Extract the load-bearing ideas of the subject — what a reference on it would teach — not the full run of
the discourse. The graph stores propositions as STEPS OF REASONING: a proposition earns its place only
when the source's arguments run through it — examples, notation glosses, and restatements that no
argument needs are not steps. Every proposition must stand alone for a reader who never saw this source:
names and statements may not lean on the source's own scaffolding (claim numbering, axiom tags, arc
labels); a claim that only matters as part of how this document is organized is not knowledge — drop it.
Extract the load-bearing ideas of the subject — what a reference on it would teach — not the full run
of the discourse. An idea earns its place only when the source's arguments run through it — examples,
notation glosses, and restatements that no argument needs are not core ideas. Every idea must stand
alone for a reader who never saw this source: names and statements may not lean on the source's own
scaffolding (claim numbering, axiom tags, arc labels); a claim that only matters as part of how this
document is organized is not knowledge — drop it.
SUBMIT with one submit_findings call: every proposition with a short name and a precise statement, and
every chain as premises + conclusion. Premises within one chain are used TOGETHER — independent arguments
for the same conclusion are separate chains. A conclusion may reappear, by id, as a premise of later
chains. Include every field even when empty — "chains": [] when the source makes no arguments. The
tool's field descriptions carry the details.`;
When the prompt carries THE GRAPH SO FAR, you are continuing a source already partly extracted: build
on it. Reference an existing idea by its id ("n_…") instead of restating it, and prefer chains that
lean on established conclusions as premises — derive new material from what is already derived. Do
not re-derive a conclusion the graph already holds unless the route is genuinely different.
SUBMIT with one submit_findings call: every NEW core idea with a short name and a precise statement,
and every chain as premises + conclusion. A chain's premises and conclusion may be ids from this
submission OR existing ids from THE GRAPH SO FAR, verbatim. Premises within one chain are used
TOGETHER — independent arguments for the same conclusion are separate chains. A conclusion may
reappear, by id, as a premise of later chains. A bare idea that no argument uses rides the
propositions list alone. Include every field even when empty — "chains": [] when the source makes no
arguments. The tool's field descriptions carry the details.`;
export const EXTRACTOR_INSTRUCTIONS = `${SESSION_PREAMBLE}\n\n${EXTRACTION_CORE}`;
export const NOTES_EXTRACTOR_INSTRUCTIONS = `${NOTES_PREAMBLE}\n\n${EXTRACTION_CORE}`;
// ── the same-source digest (owner, 2026-09-30 evening) ──
//
// Serialized digest-aware extraction REPLACED the spine stage (owner
// ruling after the live test: the post-hoc assembly pass added
// complexity without perceptible gain — the extractor itself is the
// assembler when it can see what its source has already yielded). Pure
// string fold over the derived graph; "" when the source owns nothing
// yet (the first stretch runs bare). Ids ride VERBATIM — that is what
// the extractor references in its chains (the merger's mixed-namespace
// resolution passes them through).
export function sourceDigest(graph: DerivedGraph, sourceId: string): string {
const nameOf = new Map(graph.nodes.map((n) => [n.id, n.name]));
const ours = graph.nodes.filter((n) => n.provenance.some((pt) => pt.chatId === sourceId));
const ourChains = graph.chains.filter((c) => c.provenance.some((pt) => pt.chatId === sourceId));
if (ours.length === 0 && ourChains.length === 0) return "";
const lines: string[] = [];
lines.push("THE GRAPH SO FAR — everything already extracted from this source (reference these ids verbatim):");
for (const n of ours) lines.push(` ${n.id} | ${n.name} | ${n.statement.replace(/\s+/g, " ")}`);
if (ourChains.length > 0) {
lines.push("DERIVATIONS ALREADY RECORDED (build on these; do not re-derive them):");
for (const c of ourChains) {
const names = c.premises.map((p) => nameOf.get(p) ?? p).join(" ∧ ");
lines.push(` [${c.premises.join(" + ")} ⇒ ${c.conclusion}] ${names} ⇒ ${nameOf.get(c.conclusion) ?? c.conclusion}`);
}
}
return lines.join("\n");
}
/** The extractor over ONE session transcript (the chat front door). */
function runExtractor(
engine: PiEngine | undefined,
prompt: string,
existingIds: ReadonlySet<string>,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SessionFindings> {
// Chains in (the wire), follows-edges out (the pipeline) — the
// translation seam of the 2026-09-30 redesign. Validation runs at the
// submit seam (error-back); expansion runs once, after a clean submit.
// Chains in (the wire), chains out (the store) — no translation seam
// since chains-primary. Validation runs at the submit seam (error-back)
// over the MIXED namespace: local ids + existing graph ids.
return runSubmitAgent<FindingsSubmit>({
role: "extractor",
toolName: "submit_findings",
toolDescription: "Submit the session findings.",
parameters: findingsSchema,
fill: fillFindings,
validate: validateFindingsChains,
validate: (value) => validateFindingsChains(value, existingIds),
systemPrompt: EXTRACTOR_INSTRUCTIONS,
prompt,
engine,
@@ -345,6 +401,7 @@ function runExtractor(
function runNotesExtractor(
engine: PiEngine | undefined,
prompt: string,
existingIds: ReadonlySet<string>,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<NotesFindings> {
@@ -354,7 +411,7 @@ function runNotesExtractor(
toolDescription: "Submit the notes findings.",
parameters: findingsSchema,
fill: fillFindings,
validate: validateFindingsChains,
validate: (value) => validateFindingsChains(value, existingIds),
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
prompt,
engine,
@@ -446,7 +503,7 @@ function runAuditor(
* audit+merge critical section is queued behind another job's; the
* extractor is done but the auditor may not start until the graph is
* stable. */
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "assembling" | "auditing" | "merging";
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "auditing" | "merging";
/** One in-flight pipeline run. Module-level: one process, one registry.
* Exported for the status payload's consumer typing (the dock). */
@@ -473,7 +530,6 @@ export interface RunningExtraction {
* context-window blobs read this through /api/graph/status). */
usage: {
extractor?: ExtractionStageUsage;
spine?: ExtractionStageUsage;
auditor?: ExtractionStageUsage;
};
/** True-cancellation handle (the dock's abort button): one controller per
@@ -543,7 +599,6 @@ export interface ExtractionModels {
/** Test seam: a scripted engine per stage — checked FIRST, ahead of the
* engineFor chain (production wires no direct values). */
extractor?: PiEngine;
spine?: PiEngine;
auditor?: PiEngine;
/** Settings-resolved engine per role — the same slot treatment the
* job runner got, read FRESH per stage call (a settings PUT between
@@ -622,7 +677,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
function refuseIfStagesUnready(): void {
const stages: readonly [ModelRole, PiEngine | undefined][] = [
["extractor", stageEngine("extractor", deps.extractor)],
["spine", stageEngine("spine", deps.spine)],
["auditor", stageEngine("auditor", deps.auditor)],
];
const missing = new Set<ModelSlotName>();
@@ -671,7 +725,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
}
/** Accumulate one billed step into the run's registry entry (live). */
function recordStep(jobKey: string, stage: "extractor" | "spine" | "auditor", usage: CallUsage, model: string): void {
function recordStep(jobKey: string, stage: "extractor" | "auditor", usage: CallUsage, model: string): void {
const run = runningExtractions.get(jobKey);
if (!run) return; // not running (tests driving stages directly)
run.usage[stage] ??= { model, steps: [] };
@@ -718,15 +772,28 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
}
}
async function extract(jobKey: string, view: string): Promise<SessionFindings> {
async function extract(jobKey: string, sourceId: string, view: string): Promise<SessionFindings> {
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted(); // an abort during a sync gap ends the job here
console.info(`[extraction ${jobKey}] extractor starting (${view.split("\n").length} markdown lines)`);
// THE DIGEST (owner, 2026-09-30 evening): the extractor continues a
// partly-extracted source — it sees everything this source has
// already yielded (propositions + chains, ids verbatim) and builds
// on it. Serialized batches make this read the post-merge graph of
// every earlier batch (cross-source runs stay parallel — the
// digest is source-scoped, so only SAME-source order matters).
const graph = getGraph(graphStore);
const digest = sourceDigest(graph, sourceId);
const prompt = digest === "" ? view : `${digest}\n\n${view}`;
console.info(
`[extraction ${jobKey}] extractor starting (${view.split("\n").length} markdown lines` +
`${digest === "" ? "" : `, digest ${digest.split("\n").length} lines`})`,
);
const t0 = performance.now();
const findings = await runExtractor(
stageEngine("extractor", deps.extractor),
view,
prompt,
new Set(graph.nodes.map((n) => n.id)),
signal,
(usage, model, step, finishReason, tools) => {
recordStep(jobKey, "extractor", callUsage(usage), model);
@@ -737,50 +804,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
return findings;
}
/** THE SPINE (owner, 2026-09-30): the assembly stage between extract
* and audit, INSIDE the serialized section (it reads the graph-so-far
* digest; its output rides the same audit + merge). Skipped without a
* call when the source owns nothing yet (first stretch — nothing to
* join). Identity stays the auditor's: the spine submits chains +
* connective propositions only, and the audit sees the COMBINED
* propositions — chunk findings plus connectives — never chains. */
async function assemble(
jobKey: string,
sourceId: string,
kind: "chat" | "notes",
findings: SessionFindings,
): Promise<SessionFindings> {
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted();
const graph = getGraph(graphStore);
if (!spineHasDigest(graph, sourceId)) {
console.info(`[extraction ${jobKey}] spine skipped — empty digest (first stretch)`);
return findings;
}
console.info(
`[extraction ${jobKey}] spine starting (${graph.nodes.length} graph nodes, ` +
`${findings.propositions.length} fresh propositions)`,
);
const t0 = performance.now();
const spine: SpineSubmit = await runSpine(
stageEngine("spine", deps.spine),
kind === "chat" ? SPINE_INSTRUCTIONS : NOTES_SPINE_INSTRUCTIONS,
buildSpinePrompt(graph, sourceId, findings),
new Set(findings.propositions.map((p) => p.id)),
new Set(graph.nodes.map((n) => n.id)),
signal,
(usage, model, step, finishReason, tools) => {
recordStep(jobKey, "spine", callUsage(usage), model);
console.info(`[extraction] spine step ${step}: ${finishReason}, tools: ${tools}`);
},
);
console.info(
`[extraction ${jobKey}] spine done in ${Math.round(performance.now() - t0)}ms — ` +
`${spine.chains.length} chain(s), ${spine.propositions.length} connective(s)`,
);
return spine === SPINE_EMPTY ? findings : combineFindings(findings, spine);
}
async function audit(jobKey: string, findings: SessionFindings): Promise<AuditVerdicts> {
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
@@ -849,7 +872,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
stats,
stages: {
extractor: run.usage.extractor ?? { model: "unknown", steps: [] },
spine: run.usage.spine ?? { model: "unknown", steps: [] },
auditor: run.usage.auditor ?? { model: "unknown", steps: [] },
},
};
@@ -894,6 +916,10 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
if (result.status === "fulfilled") {
stats.nodes += result.value.nodes;
stats.chains! += result.value.chains ?? 0;
// Simplification maintenance, summed like appends — but ABSENT
// when nothing dropped (deep-equal pins read cleaner).
if (result.value.droppedChains)
stats.droppedChains = (stats.droppedChains ?? 0) + result.value.droppedChains;
continue;
}
const err = result.reason;
@@ -945,71 +971,82 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
`[extraction ${chatId}] pipeline start — ${covered.uncoveredTokens} uncovered tok, ` +
`${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${chunkTokens} tok`,
);
// PER-CHUNK JOBS (owner, 2026-09-28): every batch is its own job —
// extracts fan out behind the global 3-slot semaphore ("queued"),
// audits serialize through the process-wide gate ("waiting"). A
// batch that fails is ISOLATED (its messages stay uncovered —
// honestly re-runnable); its siblings keep merging.
const settled = await Promise.allSettled(
batches.map((batch, i) => {
const part = batches.length > 1 ? { i: i + 1, n: batches.length } : undefined;
return runJob({ id: chatId, kind: "chat", part }, async (jobKey) => {
// The batch's source view: TEACHER PROSE ONLY (owner,
// 2026-09-28) — plain paragraphs, same selection as the
// coverage fingerprints (messageViewLines parity).
const view = proseView(
batch.map((slot) => slot.message),
record.title,
);
setPhase(jobKey, "queued", "chat", undefined, part);
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "chat", undefined, part);
return extract(jobKey, view);
});
// SERIALIZED AUDIT (owner 2026-09-28): audit + merge are the
// graph-mutating critical section — one job at a time,
// process-wide, so the auditor explores the graph as it
// stands AFTER every earlier job's merge. The spine rides the
// same section (owner order, 2026-09-30): extract → spine →
// audit → merge.
return withAuditLock(jobKey, "chat", undefined, async () => {
setPhase(jobKey, "assembling", "chat", undefined, part);
const combined = await assemble(jobKey, chatId, "chat", findings);
setPhase(jobKey, "auditing", "chat", undefined, part);
const auditResult = await audit(jobKey, combined);
setPhase(jobKey, "merging", "chat", undefined, part);
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
// merge. The deletion route aborts this run first and runs its purge
// as one sync block; this closes the last window — a run that slipped
// past the wind-down finds the chat gone HERE and rides the existing
// abort path (info, not error): nothing merged, nothing checkpointed.
if (!chatStore.getChat(chatId)) {
const gone = new Error(`chat deleted mid-run: ${chatId}`);
gone.name = "AbortError";
throw gone;
}
// The batch's messages, snapshotted when the run computed
// coverage — if a message mutates mid-run (a quiz answer
// merges in), the stored fingerprint won't match its new
// one and it re-opens: honest by construction.
const foldedAt = new Date().toISOString();
const batchStats = mergeFindings(chatId, combined, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
// next snapshot fetch must already reflect the new nodes).
settleGraphLayout(graphStore);
console.info(
`[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.chains ?? 0} chains`,
// PER-CHUNK JOBS (owner, 2026-09-28), SERIALIZED PER SOURCE (owner,
// 2026-09-30 evening): every batch is its own job — registry row,
// because each batch's extractor continues the source's digest
// (everything the earlier batches merged). Cross-source runs stay
// parallel; the 3-slot extract semaphore still caps concurrent
// extracts process-wide ("queued"); audits serialize through the
// process-wide gate ("waiting"). A batch that fails is ISOLATED
// (its messages stay uncovered — honestly re-runnable); its
// siblings keep merging.
const settled: PromiseSettledResult<EmissionStats>[] = [];
for (const [batchIndex, batch] of batches.entries()) {
const part = batches.length > 1 ? { i: batchIndex + 1, n: batches.length } : undefined;
try {
settled.push({
status: "fulfilled",
value: await runJob({ id: chatId, kind: "chat", part }, async (jobKey) => {
// The batch's source view: TEACHER PROSE ONLY (owner,
// 2026-09-28) — plain paragraphs, same selection as the
// coverage fingerprints (messageViewLines parity).
const view = proseView(
batch.map((slot) => slot.message),
record.title,
);
return batchStats;
});
setPhase(jobKey, "queued", "chat", undefined, part);
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "chat", undefined, part);
return extract(jobKey, chatId, view);
});
// SERIALIZED AUDIT (owner, 2026-09-28): audit + merge are the
// graph-mutating critical section — one job at a time,
// process-wide, so the auditor explores the graph as it
// stands AFTER every earlier job's merge.
return withAuditLock(jobKey, "chat", undefined, async () => {
setPhase(jobKey, "auditing", "chat", undefined, part);
const auditResult = await audit(jobKey, findings);
setPhase(jobKey, "merging", "chat", undefined, part);
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
// merge. The deletion route aborts this run first and runs its purge
// as one sync block; this closes the last window — a run that slipped
// past the wind-down finds the chat gone HERE and rides the existing
// abort path (info, not error): nothing merged, nothing checkpointed.
if (!chatStore.getChat(chatId)) {
const gone = new Error(`chat deleted mid-run: ${chatId}`);
gone.name = "AbortError";
throw gone;
}
// The batch's messages, snapshotted when the run computed
// coverage — if a message mutates mid-run (a quiz answer
// merges in), the stored fingerprint won't match its new
// one and it re-opens: honest by construction.
const foldedAt = new Date().toISOString();
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
// next snapshot fetch must already reflect the new nodes).
settleGraphLayout(graphStore);
console.info(
`[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.chains ?? 0} chains`,
);
return batchStats;
});
}),
});
}),
);
} catch (err) {
settled.push({ status: "rejected", reason: err });
// A source-wide abort (user abort / session deletion)
// ends the WHOLE run: later batches would mint fresh,
// un-aborted jobs and hang — stop here. Isolated
// FAILURES keep going (siblings still merge).
if (err instanceof Error && err.name === "AbortError") break;
}
}
const stats = aggregateJobs(chatId, settled);
console.info(
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
@@ -1070,11 +1107,20 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted(); // an abort during a sync gap ends the job here
const view = notesView(input);
console.info(`[extraction ${jobKey}] notes extractor starting (${view.split("\n").length} source lines)`);
// Same digest law as the chat door (owner, 2026-09-30 evening): the
// chunk extractor continues its own source, ids verbatim.
const graph = getGraph(graphStore);
const digest = sourceDigest(graph, input.id);
const prompt = digest === "" ? view : `${digest}\n\n${view}`;
console.info(
`[extraction ${jobKey}] notes extractor starting (${view.split("\n").length} source lines` +
`${digest === "" ? "" : `, digest ${digest.split("\n").length} lines`})`,
);
const t0 = performance.now();
const findings = await runNotesExtractor(
stageEngine("extractor", deps.extractor),
view,
prompt,
new Set(graph.nodes.map((n) => n.id)),
signal,
(usage, model, step, finishReason, tools) => {
recordStep(jobKey, "extractor", callUsage(usage), model);
@@ -1107,49 +1153,56 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
console.info(
`[extraction ${id}] notes pipeline start (${input.filePath}) — ${chunks.length} chunk(s) of ≤${chunkTokens} tok`,
);
// PER-CHUNK JOBS (owner, 2026-09-28): every chunk is its own job —
// registry row, usage record, failure domain. Extracts fan out in
// parallel behind the global 3-slot extract semaphore ("queued");
// audits serialize through the process-wide gate exactly as before
// ("waiting"). The part rides the registry entry (feed suffix
// "· i/N") only when the file actually chunked.
const settled = await Promise.allSettled(
chunks.map((chunk, i) => {
const part = chunks.length > 1 ? { i: i + 1, n: chunks.length } : undefined;
return runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
setPhase(jobKey, "queued", "notes", input.filePath, part);
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "notes", input.filePath, part);
return notesExtract(jobKey, { ...input, text: chunk, part });
});
// SERIALIZED AUDIT (owner, 2026-09-28): extracts run in
// parallel, but audit + merge one at a time, process-wide —
// the auditor explores the graph as it stands AFTER every
// earlier job's merge. Reacquired per chunk so runs
// interleave fairly. The spine rides the same section
// (owner order, 2026-09-30): extract → spine → audit → merge.
return withAuditLock(jobKey, "notes", input.filePath, async () => {
setPhase(jobKey, "assembling", "notes", input.filePath, part);
const combined = await assemble(jobKey, id, "notes", findings);
setPhase(jobKey, "auditing", "notes", input.filePath, part);
const auditResult = await audit(jobKey, combined);
setPhase(jobKey, "merging", "notes", input.filePath, part);
const merged = mergeFindings(
id,
combined,
auditResult,
{ chatStore, graphStore },
{ foldedAt: input.importedAt },
);
// The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk.
settleGraphLayout(graphStore);
console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.chains ?? 0} chains`);
return merged;
});
// PER-CHUNK JOBS (owner, 2026-09-28), SERIALIZED PER SOURCE (owner,
// 2026-09-30 evening): same law as the chat door — chunks run
// STRICTLY IN ORDER, each chunk's extractor continuing the
// source's digest (everything earlier chunks merged). Cross-source
// runs stay parallel; the semaphore caps concurrent extracts
// process-wide; audits serialize through the gate as before. The
// part rides the registry entry (feed suffix "· i/N") only when
// the file actually chunked.
const settled: PromiseSettledResult<EmissionStats>[] = [];
for (const [chunkIndex, chunk] of chunks.entries()) {
const part = chunks.length > 1 ? { i: chunkIndex + 1, n: chunks.length } : undefined;
try {
settled.push({
status: "fulfilled",
value: await runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
setPhase(jobKey, "queued", "notes", input.filePath, part);
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "notes", input.filePath, part);
return notesExtract(jobKey, { ...input, text: chunk, part });
});
// SERIALIZED AUDIT (owner, 2026-09-28): audit + merge one
// at a time, process-wide — the auditor explores the graph
// as it stands AFTER every earlier job's merge. Reacquired
// per chunk so cross-source runs interleave fairly.
return withAuditLock(jobKey, "notes", input.filePath, async () => {
setPhase(jobKey, "auditing", "notes", input.filePath, part);
const auditResult = await audit(jobKey, findings);
setPhase(jobKey, "merging", "notes", input.filePath, part);
const merged = mergeFindings(
id,
findings,
auditResult,
{ chatStore, graphStore },
{ foldedAt: input.importedAt },
);
// The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk.
settleGraphLayout(graphStore);
console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.chains ?? 0} chains`);
return merged;
});
}),
});
}),
);
} catch (err) {
settled.push({ status: "rejected", reason: err });
// Same source-wide-abort law as the chat door: stop the
// run; isolated failures keep going.
if (err instanceof Error && err.name === "AbortError") break;
}
}
const stats = aggregateJobs(id, settled);
console.info(
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
-261
View File
@@ -1,261 +0,0 @@
/**
* The spine stage (owner-approved design, 2026-09-30): the extraction
* pipeline's ASSEMBLY pass. Extraction walks a source in stretches, each
* stretch-blind — but the source's reasoning runs over the whole span,
* and the data showed it: 0 of 133 chains crossed a stretch boundary.
* The spine runs once per batch, INSIDE the serialized audit section,
* between the extract and the audit (owner-specified order):
*
* extract → [lock: spine → audit → merge] → next batch
*
* It sees the same-source digest (everything merged so far) plus the
* fresh stretch's findings, and mints ONLY what spans them: cross-stretch
* chains and the connective propositions a chain needs that no stretch
* stated on its own. The SPAN LAW is mechanical (validateSpine below):
* every chain must touch ≥1 fresh endpoint AND ≥1 existing endpoint —
* induction over the walk means no pair ever needs a second chance.
*
* Identity stays the AUDITOR's lane (owner ruling): the spine submits no
* links. Its connective propositions ride into the audit as ordinary
* findings — the auditor links them like any other — and its chains
* resolve through the merger's job-local map (endpoints may be existing
* graph ids verbatim; see mergeFindings' mixed-namespace resolution).
*
* Session and notes front doors share this stage; only the preamble
* differs (the extractor's law, verbatim).
*/
import type { Usage } from "@earendil-works/pi-ai";
import { Type, type Static } from "typebox";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
import type { PiEngine } from "../models.ts";
import type { ExtractedChain, SessionFindings } from "../../core/contracts.ts";
import type { DerivedGraph } from "../../core/graph.ts";
// ─── the prompts ─────────────────────────────────────────────────────────────
export const SESSION_SPINE_PREAMBLE =
"You are the spine stage of a learning application's extraction pipeline. One source — a teaching " +
"session, walked in stretches of the teacher's prose in reading order — has just advanced by one " +
"stretch; you join what the new stretch established into the source's accumulated line of argument.";
export const NOTES_SPINE_PREAMBLE =
"You are the spine stage of a learning application's extraction pipeline. One source — the author's " +
"personal notes, walked in stretches in reading order — has just advanced by one stretch; you join " +
"what the new stretch established into the source's accumulated line of argument.";
export const SPINE_CORE = `THE GOAL: carry the source's line of argument ACROSS stretches. Extraction walks the source in
stretches; each stretch reports its own propositions and chains — but the source's reasoning runs over
the whole span, and no stretch sees more than its own part. You see everything extracted from this
source so far, plus what the latest stretch just established. Your deliverable: the chains that JOIN them.
Every chain you submit must SPAN: its premises and conclusion may not all come from the same stretch. A
chain inside one stretch is that stretch's own work; a chain the graph already holds exists. Direction is
whatever the reasoning runs — earlier into later, or later material completing an argument opened earlier.
The extraction doctrines bind you unchanged: premises JOINTLY entail the conclusion; entailment is the
only link that earns a chain; every proposition stands alone for a reader who never saw the source;
load-bearing connections only — when in doubt, omit.
Restatements: when the fresh stretch restates a proposition the graph already holds, build your chains on
the EXISTING id, not the restatement.
SUBMIT with one submit_spine call: the connecting chains, and only the connective propositions a chain
needs that no stretch stated on its own — a missing middle step, the general claim a stretch argued only
locally. Pass empty arrays when the stretch stands alone — never omit the fields. The tool's field
descriptions carry the details.`;
export const SPINE_INSTRUCTIONS = `${SESSION_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
export const NOTES_SPINE_INSTRUCTIONS = `${NOTES_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
// ─── the submit schema ───────────────────────────────────────────────────────
const spinePropositionProps = {
id: Type.String({ description: 'Local id: "s1", "s2", … — referenced by chains.' }),
name: Type.String({
description:
"A very short label — established terminology, the term the discipline itself uses. " +
"Not a sentence, needn't be unique or self-contained: this becomes the node's global label.",
}),
statement: Type.String({
description:
"One true, self-contained, world-facing reasoning step at full precision — the connective " +
"tissue a spanning chain needs that no stretch stated on its own.",
}),
aliases: Type.Optional(
Type.Array(Type.String(), {
description: "Verbatim phrasings the source used for the same proposition, if any.",
}),
),
};
const endpoint = (role: string) =>
Type.String({
description:
`The proposition id this ${role} — EITHER an existing proposition id from the digest, ` +
'verbatim ("n_…"), OR the local id of a proposition in this submission ("s1", …) or of the ' +
'fresh stretch\'s findings ("p1", …).',
});
const spineChainSchema = Type.Object({
premises: Type.Array(endpoint("premise uses"), {
minItems: 1,
description:
"Proposition ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
}),
conclusion: endpoint("conclusion is"),
});
const spineSchema = Type.Object({
propositions: Type.Array(Type.Object(spinePropositionProps), {
description:
"Connective propositions ONLY — steps a spanning chain needs that no stretch stated on " +
"its own. Usually empty.",
}),
chains: Type.Array(spineChainSchema, {
description:
"The cross-stretch chains — each must join material that arrived at different times " +
"(≥1 endpoint from the fresh stretch, ≥1 from before it). Pass [] when the stretch " +
"stands alone — never omit the field.",
}),
});
/** What the spine SUBMITS. fillSpine normalizes it; validateSpine checks
* the span laws; the pipeline combines it with the stretch's findings
* before the audit (the auditor sees chunk + connective propositions,
* propositions only — never chains, owner 2026-09-30). */
export type SpineSubmit = Static<typeof spineSchema>;
export const SPINE_EMPTY: SpineSubmit = { propositions: [], chains: [] };
export function fillSpine(raw: SpineSubmit): SpineSubmit {
return {
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
chains: raw.chains,
};
}
/** The span laws — the LOUD channel (error-back retry), mirroring
* validateFindingsChains. `chunkIds` = the fresh stretch's finding ids
* ("p1"…); `existingIds` = every node id already in the graph. A chain
* must touch at least one FRESH endpoint (chunk finding or this
* submission's connectives) and at least one EXISTING endpoint — the
* mechanical form of the owner's all-or-nothing span rule. Identical
* re-assertions are not errors: the merger dedupes them silently. */
export function validateSpine(value: SpineSubmit, chunkIds: ReadonlySet<string>, existingIds: ReadonlySet<string>): string | null {
const localIds = new Set(value.propositions.map((p) => p.id));
for (const p of value.propositions) {
if (chunkIds.has(p.id) || existingIds.has(p.id)) {
return `propositions: "${p.id}" collides with an id this submission does not own — connective ids are fresh ("s1", "s2", …).`;
}
}
const resolves = (id: string) => chunkIds.has(id) || localIds.has(id) || existingIds.has(id);
const isFresh = (id: string) => chunkIds.has(id) || localIds.has(id);
for (const chain of value.chains) {
if (chain.premises.length === 0) {
return "chains: premises must name at least one proposition.";
}
const endpoints = [...chain.premises, chain.conclusion];
const unknown = endpoints.find((id) => !resolves(id));
if (unknown !== undefined) {
return `chains: "${unknown}" is not an existing digest id, a fresh-stretch finding id, or one of this call's proposition ids.`;
}
if (chain.premises.includes(chain.conclusion)) {
return "chains: a conclusion cannot be its own premise.";
}
if (!endpoints.some(isFresh)) {
return `chains: [${endpoints.join(", ")}] is entirely existing material — a chain the graph already had its chance to hold. Every chain must join the fresh stretch to what came before.`;
}
if (!endpoints.some((id) => existingIds.has(id))) {
return `chains: [${endpoints.join(", ")}] lives entirely inside the fresh stretch — that is the stretch extractor's work, not yours. Every chain must join the fresh stretch to what came before.`;
}
}
return null;
}
// ─── the digest ──────────────────────────────────────────────────────────────
/** Render the same-source digest + fresh findings for the spine's prompt.
* Pure: a string fold over the derived graph. The digest carries ids
* verbatim ("n_…") — that is what the spine references in its chains. */
export function buildSpinePrompt(graph: DerivedGraph, sourceId: string, findings: SessionFindings): string {
const nameOf = new Map(graph.nodes.map((n) => [n.id, n.name]));
const ours = graph.nodes.filter((n) => n.provenance.some((pt) => pt.chatId === sourceId));
const ourChains = graph.chains.filter((c) => c.provenance.some((pt) => pt.chatId === sourceId));
const lines: string[] = [];
lines.push(
ours.length > 0
? `THE GRAPH SO FAR — this source's accumulated propositions (ids verbatim, referenced by your chains):`
: `THE GRAPH SO FAR — this source has no accumulated propositions yet (nothing to join).`,
);
for (const n of ours) lines.push(` ${n.id} | ${n.name} | ${n.statement.replace(/\s+/g, " ")}`);
if (ourChains.length > 0) {
lines.push(`ARGUMENTS ALREADY RECORDED (do not re-submit these):`);
for (const c of ourChains) {
const names = c.premises.map((p) => nameOf.get(p) ?? p).join(" ∧ ");
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${nameOf.get(c.conclusion) ?? c.conclusion}`);
}
}
lines.push(``);
lines.push(`THE FRESH STRETCH — what the extractor just reported (local ids "p1", …):`);
for (const p of findings.propositions) lines.push(` ${p.id} | ${p.name} | ${p.statement.replace(/\s+/g, " ")}`);
if (findings.chains.length > 0) {
lines.push(`ITS OWN CHAINS (this stretch's local work):`);
const freshName = new Map(findings.propositions.map((p) => [p.id, p.name]));
for (const c of findings.chains) {
const names = c.premises.map((p) => freshName.get(p) ?? p).join(" ∧ ");
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${freshName.get(c.conclusion) ?? c.conclusion}`);
}
}
lines.push(``);
lines.push(`Join the source's line of argument across these two moments.`);
return lines.join("\n");
}
/** Whether the spine has anything to see at all — the first stretch of a
* source has an empty digest, and the call is skipped (no spine run, no
* spend) until a source owns at least one merged proposition. */
export function spineHasDigest(graph: DerivedGraph, sourceId: string): boolean {
return graph.nodes.some((n) => n.provenance.some((pt) => pt.chatId === sourceId));
}
// ─── the runner ──────────────────────────────────────────────────────────────
/** The spine over one batch's findings — a single submit call, retry-only
* (no exploration tools; everything it needs is in the prompt). */
export function runSpine(
engine: PiEngine | undefined,
systemPrompt: string,
prompt: string,
chunkIds: ReadonlySet<string>,
existingIds: ReadonlySet<string>,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SpineSubmit> {
return runSubmitAgent<SpineSubmit>({
role: "spine",
toolName: "submit_spine",
toolDescription: "Submit the cross-stretch chains (and any connective propositions they need).",
parameters: spineSchema,
fill: fillSpine,
validate: (value) => validateSpine(value, chunkIds, existingIds),
systemPrompt,
prompt,
engine,
signal,
onStep,
neverSubmitted: "the spine never submitted its chains",
prepareArguments: healStringifiedArrays,
});
}
/** The combined findings the AUDIT + MERGE see: the stretch's propositions
* plus the spine's connectives (ONE findings set — the auditor links
* connectives like anything else), and both chain sets (endpoints may be
* existing graph ids — the merger resolves the mixed namespace). */
export function combineFindings(findings: SessionFindings, spine: SpineSubmit): SessionFindings {
return {
propositions: [...findings.propositions, ...spine.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] }))],
chains: [...findings.chains, ...(spine.chains as ExtractedChain[])],
};
}
+2 -2
View File
@@ -16,8 +16,8 @@ import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
// ── the weak-model string-heal (owner, 2026-09-28; moved here from
// extraction.ts with the spine stage, 2026-09-30 — the heal is wire-format
// medicine shared by every submit agent) ──
// extraction.ts with the digest-aware redesign, 2026-09-30 — the heal
// is wire-format medicine shared by every submit agent) ──
//
// OpenRouter log evidence: weak models under a big submit payload often
// emit a whole array as ONE JSON-STRING instead of a native array
-5
View File
@@ -92,11 +92,6 @@ export interface ExtractionRunUsage {
stages: {
extractor: ExtractionStageUsage;
auditor: ExtractionStageUsage;
/** The spine stage (2026-09-30): cross-stretch chain assembly.
* OPTIONAL — records written before the stage existed (and runs
* whose digest was empty, so the call was skipped) carry no
* entry; readers default it to an empty step-set. */
spine?: ExtractionStageUsage;
};
}
+6
View File
@@ -63,6 +63,12 @@ export interface EmissionStats {
nodes: number;
/** New reasoning chains appended. */
chains?: number;
/** Chains the simplification pass removed in this merge (owner,
* 2026-09-30 evening — ancestry-cover / prefer-fewest-premises).
* Maintenance, not extraction: appends are counted in `chains`
* regardless. Undefined when nothing dropped (pre-simplification
* records read as zero). */
droppedChains?: number;
}
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
+2 -16
View File
@@ -61,7 +61,6 @@ export interface GraphCostSummary {
* historical spend on either is simply not displayed.) */
stages: {
extraction: StageCost;
spine: StageCost;
audit: StageCost;
};
/** Newest first, capped at `recentCap` (default 10). */
@@ -113,16 +112,11 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
* same output — no clock, no fs, no store reads. */
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const extraction = emptyStage();
const spine = emptyStage();
const audit = emptyStage();
const runs: RecentRun[] = [];
const foldExtractionRun = (run: ExtractionRunUsage, title: string) => {
const stages = run.stages ?? { extractor: { model: "unknown", steps: [] }, auditor: { model: "unknown", steps: [] } };
// The spine stage is OPTIONAL on the record (2026-09-30): records
// written before it existed (and runs whose digest was empty, so
// the call was skipped) carry no entry — default to empty steps.
const spineStage = stages.spine ?? { model: "unknown", steps: [] };
// Per-stage: tokens always count; dollars only when priced.
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
@@ -130,14 +124,6 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
if (spineStage.steps.length > 0) {
spine.runs++;
mergeTokens(spine.tokens, spineStage.steps);
const spDollars = stageDollars(spineStage.model, spineStage.steps);
if (spDollars === null) spine.unpricedRuns++;
else spine.dollars += spDollars;
}
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
@@ -149,7 +135,7 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const runDollars = allStages.every((d) => d === null)
? null
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
const allSteps = [...stages.extractor.steps, ...spineStage.steps, ...stages.auditor.steps];
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
runs.push({
startedAt: run.startedAt,
at: run.at,
@@ -177,7 +163,7 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
return {
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, spine, audit },
stages: { extraction, audit },
runs: capped,
};
}
+133 -19
View File
@@ -7,22 +7,22 @@
*
* extractor (LLM) → SessionFindings: the raw reasoning chains —
* propositions (lonely relevant ones included) and
* chains, from the stretch alone (one batch of the
* source). Fully trusted: no stage re-checks it.
* spine (LLM) → the ASSEMBLY stage (2026-09-30): cross-stretch
* chains + connective propositions joining the
* fresh stretch to the same-source digest (the span
* law is mechanical — src/agent/subagents/spine.ts).
* No links — identity stays the auditor's.
* chains, from the stretch (one batch) PLUS the
* same-source digest (digest-aware, 2026-09-30
* evening: the extractor continues its own source,
* references existing ids verbatim, assembles
* cross-stretch itself). Fully trusted: no stage
* re-checks it.
* auditor (LLM) → AuditVerdicts: identity links (local id → an existing
* node, or create) via graph-exploration tools —
* links ONLY (owner, 2026-09-28 evening: the auditor
* never mints edges; cross-source connection happens
* solely through identified equivalences). Never sees
* the source; never drops; never sees chains — its
* input is the chunk propositions + the spine's
* connectives, propositions only.
* input is the fresh propositions only.
* merger (HERE) → mergeFindings(): deterministic ledger appends
* + the chain-simplification maintenance pass
* (2026-09-30 evening — simplifyChains below).
*
* The merger is deliberately the ONLY stage that touches the ledgers, and it
* is pure code: the LLM supplies exactly the judgment calls (what the
@@ -200,11 +200,12 @@ export function mergeFindings(
mapping.set(p.id, graphNodeId);
}
// 2. Chains — the extractor's and the spine's (the two chain sources;
// the stored truth, chains-primary, owner 2026-09-30). Endpoints
// 2. Chains — the extractor's alone since the digest-aware redesign
// (owner, 2026-09-30 evening: the extractor continues its own
// source, so ITS chains may span stretches and digests). Endpoints
// resolve through the job-local map OR pass through verbatim when
// they name an existing node (the spine references digest ids
// directly); chains carry the run's chat-level provenance point and
// they name an existing node (digest references, ids verbatim);
// chains carry the run's chat-level provenance point and
// dedupe by (premises, conclusion) already asserted by THIS chat —
// an assertion by another chat stays untouched (provenance unions
// at read time; a known argument re-stated appends under the SAME
@@ -218,11 +219,11 @@ export function mergeFindings(
if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
}
const emitChain = (chain: ExtractedChain): void => {
// Mixed namespace (spine stage, 2026-09-30): an endpoint resolves
// through the job-local map (this submit's propositions — chunk
// findings and spine connectives alike) OR passes through verbatim
// when it names an existing node (a spine chain referencing digest
// ids directly). Anything else is unresolvable — skip.
// Mixed namespace (digest-aware extraction, 2026-09-30): an
// endpoint resolves through the job-local map (this submit's
// propositions) OR passes through verbatim when it names an
// existing node (THE GRAPH SO FAR references, ids verbatim).
// Anything else is unresolvable — skip.
const resolve = (endpoint: string): string | undefined =>
mapping.get(endpoint) ?? (existingIds.has(endpoint) ? endpoint : undefined);
const conclusion = resolve(chain.conclusion);
@@ -241,7 +242,27 @@ export function mergeFindings(
assertedByChat.add(key);
stats.chains!++;
};
for (const chain of findings.chains) emitChain(chain); // the extractor's chains + the spine's spanning chains — the two chain sources
for (const chain of findings.chains) emitChain(chain); // the extractor's chains — the one chain source (digest-aware, owner 2026-09-30)
// 2b. Simplification (owner, 2026-09-30 evening) — the deterministic
// dedupe pass over the WHOLE chain ledger (this merge runs inside
// the serialized audit section, so the rewrite is race-free). Two
// rules, decided per conclusion group, greedy in (premiseCount,
// createdAt, key) order — the leanest, earliest derivation stands:
// • prefer-fewest-premises: a chain whose premise set strictly
// contains a kept chain's premise set is redundant;
// • ancestry-cover: a chain whose every premise is a premise of a
// kept chain, or derivable from one (transitively, through the
// FULL pre-simplification chain set) is redundant — [A,B]⟹X
// plus [X,C]⟹Y makes [A,B,C]⟹Y redundant.
// Dropped keys take every provenance line with them (the rewrite is
// atomic); stats count APPENDS only — simplification is maintenance.
const allChains = ledgers.readChains();
const keptChains = simplifyChains(allChains);
if (keptChains.length !== allChains.length) {
ledgers.rewriteLedgers({ chains: keptChains });
stats.droppedChains = allChains.length - keptChains.length;
}
// 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
// only now, after every append succeeded, is the batch covered. The
@@ -257,6 +278,99 @@ export function mergeFindings(
return stats;
}
/** The deterministic chain-simplification pass (owner, 2026-09-30
* evening): strips redundant derivations — same-conclusion chains whose
* premises add nothing a leaner kept chain doesn't already derive. Pure
* over the input list; the caller owns the ledger rewrite. Exported for
* tests.
*
* Same (premises, conclusion) lines are ONE logical chain (read-time
* merge semantics) — a key is kept or dropped as a whole, every
* provenance line with it. */
export function simplifyChains(lines: GraphChain[]): GraphChain[] {
if (lines.length < 2) return lines;
// Group lines by chain key (logical chains).
const groups = new Map<string, GraphChain[]>();
for (const line of lines) {
const key = chainKey(line.premises, line.conclusion);
const bucket = groups.get(key);
if (bucket === undefined) groups.set(key, [line]);
else bucket.push(line);
}
// Ancestry over the FULL chain set: every node id reachable as a
// premise behind an id (A⟹X makes A an ancestor of X; [X,C]⟹Y makes
// both A and C ancestors of Y). Memoized walk, cycle-guarded (a cycle
// cannot be minted by the extractor — a premise equal to the
// conclusion is refused — but the walk stays honest if one ever lands).
const premisesOf = new Map<string, string[][]>();
for (const line of lines) {
const bucket = premisesOf.get(line.conclusion);
if (bucket === undefined) premisesOf.set(line.conclusion, [line.premises]);
else bucket.push(line.premises);
}
const ancestryMemo = new Map<string, Set<string>>();
const ancestry = (id: string, visiting: Set<string>): Set<string> => {
const memo = ancestryMemo.get(id);
if (memo !== undefined) return memo;
const out = new Set<string>();
if (visiting.has(id)) return out;
visiting.add(id);
for (const premises of premisesOf.get(id) ?? []) {
for (const premise of premises) {
out.add(premise);
for (const ancestor of ancestry(premise, visiting)) out.add(ancestor);
}
}
visiting.delete(id);
ancestryMemo.set(id, out);
return out;
};
// Per conclusion: greedy in (premiseCount, createdAt, key).
const byConclusion = new Map<
string,
Array<{ key: string; premises: string[]; createdAt: string }>
>();
for (const [key, bucket] of groups) {
const head = bucket[0]!; // the representative — its premises stand
const entry = { key, premises: head.premises, createdAt: head.createdAt };
const list = byConclusion.get(head.conclusion);
if (list === undefined) byConclusion.set(head.conclusion, [entry]);
else list.push(entry);
}
const droppedKeys = new Set<string>();
for (const candidates of byConclusion.values()) {
candidates.sort(
(a, b) =>
a.premises.length - b.premises.length ||
a.createdAt.localeCompare(b.createdAt) ||
a.key.localeCompare(b.key),
);
const kept: typeof candidates = [];
for (const candidate of candidates) {
const candidateSet = new Set(candidate.premises);
const redundant = kept.some((k) => {
// Rule 1 — prefer-fewest-premises: strict subset (equal keys are
// grouped, so a kept ⊆ candidate with fewer premises is strict).
if (k.premises.every((p) => candidateSet.has(p))) return true;
// Rule 2 — ancestry-cover: every candidate premise is a kept
// premise, or reachable behind one.
const covered = new Set(k.premises);
for (const p of k.premises) {
for (const ancestor of ancestry(p, new Set())) covered.add(ancestor);
}
return candidate.premises.every((p) => covered.has(p));
});
if (redundant) droppedKeys.add(candidate.key);
else kept.push(candidate);
}
}
if (droppedKeys.size === 0) return lines;
return lines.filter((line) => !droppedKeys.has(chainKey(line.premises, line.conclusion)));
}
// ─── derivation (reads only — everything below is recomputable) ─────────────
/** The merged STORED truth: propositions + chains (chains-primary, owner
-5
View File
@@ -45,7 +45,6 @@ export const BACKGROUND_MODEL_ROLES = [
"demoBuilder",
"visualMaker",
"extractor",
"spine",
"auditor",
"partitioner",
"problemGenerator",
@@ -227,10 +226,6 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
researcher: "cheap",
demoBuilder: "cheap",
extractor: "cheap",
// The spine (2026-09-30): cross-stretch assembly — judgment-dense
// work over the whole same-source digest, strong tier like the
// practice trio.
spine: "strong",
auditor: "cheap",
visualMaker: "vision",
pdfReader: "vision",
+286 -313
View File
@@ -46,21 +46,14 @@ import {
createExtractionPipeline,
extractionStatuses,
transcriptView,
sourceDigest,
EXTRACTION_CORE,
SESSION_PREAMBLE,
NOTES_PREAMBLE,
EXTRACTOR_INSTRUCTIONS,
NOTES_EXTRACTOR_INSTRUCTIONS,
} from "../src/agent/subagents/extraction.ts";
import {
SPINE_CORE,
SPINE_INSTRUCTIONS,
NOTES_SPINE_INSTRUCTIONS,
validateSpine,
buildSpinePrompt,
spineHasDigest,
type SpineSubmit,
} from "../src/agent/subagents/spine.ts";
import { simplifyChains } from "../src/core/graph.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
import { coverageOf, messageViewLines } from "../src/core/coverage.ts";
import { chatStore } from "../src/store/chats.ts";
@@ -284,15 +277,6 @@ function submitModel(name: string, input: unknown, onCall?: () => void): PiEngin
* the timing control for the serialized-audit tests (owner 2026-09-28):
* the model call has STARTED (onCall fired, the run holds the audit
* gate) but will not resolve until the test opens the gate. */
/** The spine fixture every pipeline construction carries (stage gate is
* strict, owner 2026-09-30): the honest common case — the stretch stands
* alone, empty submit. One-shot; multi-batch tests that need a REAL
* spanning spine build their own submitModel("submit_spine", …). */
function spineModel(): PiEngine {
return submitModel("submit_spine", { propositions: [], chains: [] });
}
function gatedSubmitModel(name: string, input: unknown, gate: Promise<void>, onCall?: () => void): PiEngine {
return fauxEngine([
async () => {
@@ -402,7 +386,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chains: FINDINGS.chains,
});
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, spine: spineModel(), auditor: submitModel("submit_review", verdict) });
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
const stats = await pipeline.runExtraction(chatId);
assert.deepEqual(stats, { nodes: 2, chains: 1 });
});
@@ -418,7 +402,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor,
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
@@ -447,7 +430,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", verdict),
});
const stats = await pipeline.runExtraction(chatId);
@@ -496,7 +478,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers });
await assert.rejects(pipeline.runExtraction(chatId), (err: unknown) => {
assert.ok(err instanceof ModelsNotConfiguredError);
assert.deepEqual(err.envelope.missing, ["cheap", "strong"]); // extractor + auditor cheap, spine strong
assert.deepEqual(err.envelope.missing, ["cheap"]); // extractor + auditor, both cheap-tier
assert.equal(err.envelope.code, "models_not_configured");
return true;
});
@@ -514,7 +496,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor,
spine: spineModel(),
auditor,
providerKeyMissing: (provider, tier) => {
seen.push([provider, tier]);
@@ -542,15 +523,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: fauxEngine([]),
spine: spineModel(),
auditor: fauxEngine([]),
providerKeyMissing: (provider, tier) => (calls++ < 2 ? null : providerKeyMissing(provider, [tier])),
providerKeyMissing: (provider, tier) => (calls++ < 1 ? null : providerKeyMissing(provider, [tier])),
});
await assert.rejects(pipeline.runExtraction(chatId), (err: unknown) => {
assert.ok(err instanceof ProviderKeyMissingError, "the auditor's refusal surfaces after extractor + spine passed");
assert.ok(err instanceof ProviderKeyMissingError, "the auditor's refusal surfaces after the extractor passed");
return true;
});
assert.equal(calls, 3, "all three stages were checked, in order");
assert.equal(calls, 2, "both stages were checked, in order");
assert.equal(ledgers.readCoverage().length, 0);
});
@@ -563,7 +543,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", verdict),
});
const stats = await pipeline.runExtraction(chatId);
@@ -589,7 +568,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
engineFor: (role) => {
consulted.push(role);
if (role === "extractor") return submitModel("submit_findings", FINDINGS);
if (role === "spine") return submitModel("submit_spine", { propositions: [], chains: [] });
if (role === "auditor") return submitModel("submit_review", verdict);
return submitModel("submit_labels", { labels: [] });
},
@@ -597,9 +575,9 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const stats = await pipeline.runExtraction(chatId);
assert.equal(stats.nodes, 2, "the chain-fed pipeline ran end to end — both propositions merged");
// The stages were resolved through the chain, in pipeline order
// (extractor → spine → auditor; the labeler is fire-and-forget
// (extractor → auditor; the labeler is fire-and-forget
// garnish for any other role).
assert.deepEqual(consulted.slice(0, 3), ["extractor", "spine", "auditor"]);
assert.deepEqual(consulted.slice(0, 2), ["extractor", "auditor"]);
});
it("direct test values still WIN over engineFor (the priority pin)", async () => {
@@ -610,7 +588,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", verdict),
// If the direct values did NOT win, this throw would surface.
engineFor: (role) => {
@@ -629,7 +606,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: silentModel(), // never submits the review
});
// The old run-level throw is gone: one failed job is absorbed at
@@ -643,7 +619,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: silentModel(),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await failingExtractor.runExtraction(chatId), { nodes: 0, chains: 0 });
@@ -666,13 +641,11 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
// 4096 budget: the fixture is sized to split in two (the factory
// default doubled to 8192 with the spine stage, 2026-09-30).
// default doubled to 8192, 2026-09-30).
getChunkTokens: () => 4096,
engineFor: (role) =>
role === "extractor"
? fauxEngine([countingStep])
: role === "spine"
? submitModel("submit_spine", { propositions: [], chains: [] })
: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await mixed.runExtraction(chunkyId), { nodes: 2, chains: 1 }, "batch 1's merge survives batch 2's failure");
@@ -708,7 +681,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
// Each stage's first generate is the observation point: the
// registry must already carry this chat at the right phase.
extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())),
spine: spineModel(),
auditor: submitModel("submit_review", verdict, () => seen.push(phaseOf())),
});
await pipeline.runExtraction(chatId);
@@ -735,7 +707,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", findingsA),
spine: spineModel(),
auditor: gatedSubmitModel("submit_review", { links: [] }, gate, () => {
aAuditing = true;
}),
@@ -753,7 +724,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
// THE pin: when B's auditor is finally invoked, A's merge must
// already be in the ledgers — B explores the post-merge graph,
// which is the entire point of the gate.
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }, () => {
bAuditorCalled = true;
bSawAlphasMerge = ledgers.readNodes().some((n) => n.name === "Alpha claim");
@@ -791,7 +761,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", single("pa", "Alpha claim", "Alpha statement")),
spine: spineModel(),
auditor: gatedSubmitModel("submit_review", { links: [] }, gate),
});
const runA = pipelineA.runExtraction(a.chatId);
@@ -802,7 +771,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", single("pb", "Beta claim", "Beta statement")),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }, () => {
bAuditorCalled = true;
}),
@@ -825,7 +793,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipelineC.runExtraction(c.chatId), { nodes: 1, chains: 0 });
@@ -853,7 +820,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
// During the auditor's generate, the registry must already carry
// the extractor's fully accumulated live usage (2 steps × 10/5).
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", verdict, () =>
usageSeen.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage.extractor?.steps.at(-1)?.inputTokens),
),
@@ -892,7 +858,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: hangingModel(), // hangs until aborted — no step finishes
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
const runPromise = pipeline.runExtraction(chatId);
@@ -943,7 +908,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: gatedExtractor,
spine: spineModel(),
auditor: silentModel(),
});
@@ -980,7 +944,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: gatedExtractor,
spine: spineModel(),
auditor: silentModel(),
});
// A SECOND instance over DIFFERENT ledgers — the observer a separate
@@ -990,7 +953,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: memoryLedgers(),
extractor: silentModel(),
spine: spineModel(),
auditor: silentModel(),
});
@@ -1017,7 +979,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: silentModel(),
spine: spineModel(),
auditor: silentModel(),
});
await assert.rejects(pipeline.runExtraction("chat_does_not_exist"), /Chat not found/);
@@ -1087,7 +1048,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor,
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, chains: 1 });
@@ -1123,7 +1083,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor,
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
// Independent arguments for the same conclusion are separate chains;
@@ -1144,7 +1103,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor,
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
@@ -1189,7 +1147,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
// The first submit was schema-VALID — only the chain law caught it;
@@ -1208,7 +1165,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
@@ -1225,7 +1181,6 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
@@ -1235,12 +1190,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
// The core carries the goal + submission mechanics.
assert.match(EXTRACTION_CORE, /JOINTLY entail a conclusion/);
assert.match(EXTRACTION_CORE, /for the same conclusion are separate chains/);
assert.match(EXTRACTION_CORE, /Entailment is the only link that earns a chain/);
assert.match(EXTRACTION_CORE, /Entailment is the only link that earns a\s+chain/);
assert.match(EXTRACTION_CORE, /what a reference on it would teach/);
assert.match(EXTRACTION_CORE, /stand alone for a reader who never saw this source/);
assert.match(EXTRACTION_CORE, /may not lean on the source's own scaffolding/);
assert.match(EXTRACTION_CORE, /stand\s+alone for a reader who never saw this source/);
assert.match(EXTRACTION_CORE, /may not lean on the source's own\s+scaffolding/);
assert.match(EXTRACTION_CORE, /SUBMIT with one submit_findings call/);
assert.match(EXTRACTION_CORE, /"chains": \[\] when the source makes no arguments/);
assert.match(EXTRACTION_CORE, /"chains": \[\] when the source makes no\s+arguments/);
// The digest doctrine (2026-09-30 evening) + the core-ideas language.
assert.match(EXTRACTION_CORE, /THE GOAL: uncover the source's CORE IDEAS/);
assert.match(EXTRACTION_CORE, /everything derives from the foundations/);
assert.match(EXTRACTION_CORE, /build\s+on it\./);
// Assembly: each front door = its preamble + the shared core.
assert.ok(EXTRACTOR_INSTRUCTIONS.startsWith(SESSION_PREAMBLE));
@@ -1250,8 +1209,8 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
// The preambles differ in exactly their source description.
assert.notEqual(SESSION_PREAMBLE, NOTES_PREAMBLE);
assert.match(SESSION_PREAMBLE, /a teaching session, rendered as the teacher's prose in reading order/);
assert.match(NOTES_PREAMBLE, /one chunk of the author's personal notes, rendered in reading order/);
assert.match(SESSION_PREAMBLE, /a teaching\s+session, rendered as the teacher's prose in reading order/);
assert.match(NOTES_PREAMBLE, /the\s+author's personal notes, rendered in reading order/);
// NO RULE ARMOR (owner, 2026-09-30): the defensive lists are CUT
// from both contextualizations.
@@ -1274,7 +1233,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
graphStore: ledgers,
extractor: submitModel("submit_findings", NOTES_FINDINGS),
// During the auditor's call the registry must show the notes run.
spine: spineModel(),
auditor: submitModel("submit_review", NOTES_VERDICT, () => {
const run = extractionStatuses().find((r) => r.chatId.startsWith("src_"));
seen.push({ kind: run?.kind, label: run?.label });
@@ -1334,8 +1292,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
engineFor: (role) =>
role === "extractor"
? extractorStep()
: role === "spine"
? submitModel("submit_spine", { propositions: [], chains: [] })
: submitModel("submit_review", { links: [] }),
getChunkTokens: () => 20, // 80 chars/line-groups → several chunks
});
@@ -1389,7 +1345,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", NOTES_FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", NOTES_VERDICT),
});
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
@@ -1420,7 +1375,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
chatStore,
graphStore: ledgers,
extractor: hangingModel(),
spine: spineModel(),
auditor: submitModel("submit_review", NOTES_VERDICT),
});
const runPromise = pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
@@ -1502,10 +1456,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
chatStore,
graphStore: ledgers,
// 4096 budget: the fixture is sized to split in two (the factory
// default doubled to 8192 with the spine stage, 2026-09-30).
// default doubled to 8192, 2026-09-30).
getChunkTokens: () => 4096,
extractor: fauxEngine([findingsStep((v) => views.push(v)), findingsStep((v) => views.push(v))]),
spine: spineModel(),
auditor: fauxEngine([passThroughAuditStep(), passThroughAuditStep()]),
});
const stats = await pipeline.runExtraction(chatId);
@@ -1554,7 +1507,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
chatStore,
graphStore: ledgers,
// 4096 budget: the fixture is sized to split in two (the factory
// default doubled to 8192 with the spine stage, 2026-09-30).
// default doubled to 8192, 2026-09-30).
getChunkTokens: () => 4096,
// Batch 1 submits and merges; batch 2's extractor call HANGS
// until the abort fires — the dock's abort button, mid-run.
@@ -1570,7 +1523,6 @@ describe("the chunked pipeline (extraction coverage)", () => {
sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
}),
]),
spine: spineModel(),
auditor: fauxEngine([passThroughAuditStep()]),
});
const runPromise = pipeline.runExtraction(chatId);
@@ -1601,7 +1553,6 @@ describe("the chunked pipeline (extraction coverage)", () => {
chatStore,
graphStore: ledgers,
extractor: fauxEngine([findingsStep((v) => reRunViews.push(v))]),
spine: spineModel(),
auditor: fauxEngine([passThroughAuditStep()]),
});
await finisher.runExtraction(chatId);
@@ -1626,7 +1577,6 @@ describe("the chunked pipeline (extraction coverage)", () => {
chatStore,
graphStore: ledgers,
extractor: fauxEngine([findingsStep()]),
spine: spineModel(),
auditor: fauxEngine([passThroughAuditStep()]),
});
await pipeline.runExtraction(chatId);
@@ -1662,7 +1612,6 @@ describe("the chunked pipeline (extraction coverage)", () => {
chatStore,
graphStore: ledgers,
extractor: fauxEngine([findingsStep((v) => reRunViews.push(v))]),
spine: spineModel(),
auditor: fauxEngine([passThroughAuditStep()]),
});
await reOpener.runExtraction(chatId);
@@ -1685,11 +1634,11 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
const wideText = () =>
Array.from({ length: 8 }, (_, i) => `note ${i}: ${"x".repeat(90)}`).join("\n");
it("fans out one job per chunk: 3 extract concurrently (the global semaphore), the rest queue, every job is its own feed row", async () => {
it("one job per chunk, SERIALIZED per source: the registry shows one live row at a time, and a source-wide abort ends the run", async () => {
const ledgers = memoryLedgers();
// The extractor call hangs until its ABORT — the observation window:
// with all jobs parked, the registry must show exactly 3 extracting
// and 5 queued, one row per chunk with its part.
// the parked chunk-1 job must be the ONLY live row (later chunks
// mint only when their turn comes, digest order, owner 2026-09-30).
const abortingStep: FauxResponseStep = (_context, options) =>
new Promise((_resolve, reject) => {
const sig = options?.signal;
@@ -1711,26 +1660,21 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
const runPromise = pipeline.importNotes({ filePath: "/vault/parallel.md", text: wideText() });
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
await until(() => extractionStatuses().filter((r) => r.chatId === srcId).length === 8, 500);
await until(() => extractionStatuses().some((r) => r.chatId === srcId && r.phase === "extracting"), 500);
const rows = extractionStatuses().filter((r) => r.chatId === srcId);
assert.equal(rows.length, 8, "one feed row per chunk job");
assert.equal(rows.filter((r) => r.phase === "extracting").length, 3, "the semaphore admits exactly 3 extractor calls");
assert.equal(rows.filter((r) => r.phase === "queued").length, 5, "the rest wait for an extract slot, not the model");
const parts = rows.map((r) => r.part).filter((p): p is { i: number; n: number } => Boolean(p));
assert.equal(parts.length, 8, "multi-chunk sources stamp every row with its part");
assert.deepEqual(
[...parts].sort((a, b) => a.i - b.i).map((p) => `${p.i}/${p.n}`),
Array.from({ length: 8 }, (_, i) => `${i + 1}/8`),
);
assert.equal(rows.length, 1, "one live row — the source's chunks run strictly in order");
assert.equal(rows[0]!.phase, "extracting");
assert.deepEqual(rows[0]!.part, { i: 1, n: 8 }, "the row carries its part");
// SOURCE-WIDE abort: every chunk job of the fan-out dies together.
// SOURCE-WIDE abort: the parked job dies and the run ENDS — no
// later chunk ever mints (fresh rows would hang un-aborted).
assert.equal(abortExtraction(srcId), true);
assert.deepEqual(await runPromise, { nodes: 0, chains: 0 });
assert.equal(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
// Per-job usage: all 8 chunk jobs persisted their (failed) spend.
// Per-job usage: the ONE minted job persisted its (failed) spend.
const source = ledgers.readSources().find((src) => src.id === srcId)!;
assert.equal(source.extractions?.length, 8, "one usage record per chunk job — even failed jobs bill honestly");
assert.equal(source.extractions?.length, 1, "only the aborted job bills — later chunks never ran");
assert.ok(source.extractions!.every((entry) => entry.stats === undefined), "no job merged — no stats");
});
@@ -1764,28 +1708,25 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
auditInFlight--;
return reviewMessage;
};
const spineMessage = fauxAssistantMessage(
[fauxToolCall("submit_spine", { propositions: [], chains: [] } as unknown as ToolCall["arguments"], { id: "spine-call" })],
{ stopReason: "toolUse" },
);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
engineFor: (role) =>
role === "extractor"
? fauxEngine([slowExtract])
: role === "spine"
? fauxEngine([() => spineMessage])
: fauxEngine([slowAudit]),
role === "extractor" ? fauxEngine([slowExtract]) : fauxEngine([slowAudit]),
getChunkTokens: () => 20,
});
const stats = await pipeline.importNotes({ filePath: "/vault/parallel-2.md", text: wideText() });
assert.equal(extractPeak, 3, "8 chunks, 3 permits — the peak pins both the fan-out and the cap");
// SERIALIZED PER SOURCE (owner, 2026-09-30 evening): one source's
// chunks run strictly in order — each extractor continues the
// digest of every earlier merge, so the peak within one source is
// ONE concurrent extract. (Cross-source runs still parallelize;
// the 3-permit semaphore still caps those.)
assert.equal(extractPeak, 1, "one source, one extract at a time — strict order");
assert.equal(auditPeak, 1, "audits serialize behind the gate — never two at once");
// Identical findings in every chunk, one merge PER chunk (jobs are
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
// 8 × 1 edge. The auditor's links, not the merger, dedupe identity.
// 8 × 1 chain. The auditor's links, not the merger, dedupe identity.
assert.deepEqual(stats, { nodes: 16, chains: 8 });
assert.equal(ledgers.readNodes().length, 16);
});
@@ -1802,7 +1743,6 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
spine: spineModel(),
auditor: submitModel("submit_review", { links: [] }, () => chatStore.deleteChat(chatId)),
});
const stats = await pipeline.runExtraction(chatId);
@@ -1827,118 +1767,57 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
});
});
// ─── the spine stage (owner, 2026-09-30): extract → spine → audit → merge ────
// ─── digest-aware serialized extraction + chain simplification (owner, 2026-09-30 evening) ────
describe("the spine stage (cross-stretch assembly)", () => {
it("the system prompt is goal-first: span doctrine + doctrines carried over, one shared core", () => {
assert.match(SPINE_CORE, /carry the source's line of argument ACROSS stretches/);
assert.match(SPINE_CORE, /may not all come from the same stretch/);
assert.match(SPINE_CORE, /JOINTLY entail the conclusion/);
assert.match(SPINE_CORE, /[Ee]ntailment is the\s+only link that earns a chain/);
assert.match(SPINE_CORE, /when in doubt, omit/);
assert.match(SPINE_CORE, /build your chains on\s+the EXISTING id, not the restatement/);
assert.match(SPINE_CORE, /SUBMIT with one submit_spine call/);
// Assembly: each front door = its preamble + the shared core.
assert.ok(SPINE_INSTRUCTIONS.includes(SPINE_CORE));
assert.ok(NOTES_SPINE_INSTRUCTIONS.includes(SPINE_CORE));
assert.match(SPINE_INSTRUCTIONS, /a teaching session/);
assert.match(NOTES_SPINE_INSTRUCTIONS, /the author's personal notes/);
describe("digest-aware serialized extraction (the extractor is the assembler)", () => {
it("the system prompt is goal-first: core ideas, foundations direction, build-on-the-digest doctrine", () => {
assert.match(EXTRACTION_CORE, /THE GOAL: uncover the source's CORE IDEAS and show how everything derives from the foundations/);
assert.match(EXTRACTION_CORE, /JOINTLY entail a conclusion/);
assert.match(EXTRACTION_CORE, /[Ee]ntailment is the\s+only link that earns a\s+chain/);
assert.match(EXTRACTION_CORE, /examples,\s+notation glosses, and restatements that no argument needs are not core ideas/);
assert.match(EXTRACTION_CORE, /build\s+on it\. Reference an existing idea by its id/);
assert.match(EXTRACTION_CORE, /OR existing ids from THE GRAPH SO FAR, verbatim/);
// Each front door = its preamble + the shared core; the preambles
// name the continuing-source posture.
assert.ok(EXTRACTOR_INSTRUCTIONS.includes(EXTRACTION_CORE));
assert.ok(NOTES_EXTRACTOR_INSTRUCTIONS.includes(EXTRACTION_CORE));
assert.match(SESSION_PREAMBLE, /building on everything already extracted from this source/);
assert.match(NOTES_PREAMBLE, /building on everything already extracted from this source/);
});
it("validateSpine enforces the span law: every chain joins fresh material to existing material", () => {
const chunkIds = new Set(["p1"]);
const existing = new Set(["n_a", "n_b"]);
const ok: SpineSubmit = { propositions: [], chains: [{ premises: ["n_a", "p1"], conclusion: "n_b" }] };
assert.equal(validateSpine(ok, chunkIds, existing), null);
// Connectives count as fresh; existing endpoints may sit anywhere.
const connective: SpineSubmit = {
propositions: [{ id: "s1", name: "C", statement: "c", aliases: [] }],
chains: [{ premises: ["s1", "p1"], conclusion: "n_a" }],
};
assert.equal(validateSpine(connective, chunkIds, existing), null);
// Empty submit is VALID — the honest common case (stretch stands alone).
assert.equal(validateSpine({ propositions: [], chains: [] }, chunkIds, existing), null);
// All-existing: the graph already had its chance — rejected.
assert.match(
validateSpine({ propositions: [], chains: [{ premises: ["n_a"], conclusion: "n_b" }] }, chunkIds, existing) ?? "",
/entirely existing material/,
);
// All-fresh: the stretch extractor's own work — rejected.
assert.match(
validateSpine(
{ propositions: [{ id: "s1", name: "C", statement: "c", aliases: [] }], chains: [{ premises: ["p1"], conclusion: "s1" }] },
chunkIds,
existing,
) ?? "",
/entirely inside the fresh stretch/,
);
// Unknown ids never pass silently.
assert.match(
validateSpine({ propositions: [], chains: [{ premises: ["p1"], conclusion: "zz" }] }, chunkIds, existing) ?? "",
/not an existing digest id/,
);
// Self-reference and id collisions are loud.
assert.match(
validateSpine({ propositions: [], chains: [{ premises: ["n_a", "p1"], conclusion: "p1" }] }, chunkIds, existing) ?? "",
/cannot be its own premise/,
);
assert.match(
validateSpine(
{ propositions: [{ id: "p1", name: "X", statement: "x", aliases: [] }], chains: [] },
chunkIds,
existing,
) ?? "",
/collides with an id this submission does not own/,
);
});
it("buildSpinePrompt renders the digest verbatim-id + the fresh stretch; spineHasDigest is same-source only", () => {
it("sourceDigest renders the same-source truth (ids verbatim) and stays empty for a barren source", () => {
const seed = (id: string, name: string, chatId: string): GraphNode => ({
id,
name,
statement: `statement of ${name}`,
aliases: [],
createdAt: "2026-09-30T00:00:00.000Z",
provenance: [{ chatId, messageId: "" }],
});
const chain = (id: string, premises: string[], conclusion: string, chatId: string): GraphChain => ({
id,
premises,
conclusion,
createdAt: "2026-09-30T00:00:01.000Z",
provenance: [{ chatId, messageId: "" }],
});
const graph = {
nodes: [
{
id: "n_a",
name: "Self-ownership",
statement: "s",
provenance: [{ chatId: "src_1" }],
},
{ id: "n_b", name: "Foreign", statement: "s", provenance: [{ chatId: "src_2" }] },
],
chains: [
{
id: "arg_x",
premises: ["n_a"],
conclusion: "n_b",
provenance: [{ chatId: "src_1" }],
},
],
} as unknown as Parameters<typeof buildSpinePrompt>[0];
const findings: SessionFindings = {
propositions: [{ id: "p1", name: "Fresh", statement: "f", aliases: [] }],
chains: [],
};
const prompt = buildSpinePrompt(graph, "src_1", findings);
assert.match(prompt, /THE GRAPH SO FAR/);
assert.match(prompt, /n_a \| Self-ownership/);
assert.doesNotMatch(prompt, /n_b \| Foreign/); // other sources' nodes stay out
assert.match(prompt, /\[n_a ⟹ n_b\]/); // recorded chains shown as ids
assert.match(prompt, /THE FRESH STRETCH/);
assert.match(prompt, /p1 \| Fresh/);
// The chain line names the foreign conclusion via the id-name map —
// ids always render, names come from anywhere in the graph.
assert.match(prompt, /Self-ownership ⟹ Foreign/);
assert.equal(spineHasDigest(graph, "src_1"), true);
assert.equal(spineHasDigest(graph, "src_9"), false);
nodes: [seed("n_a", "Self-ownership", "src_1"), seed("n_x", "Foreign key", "src_2")],
chains: [chain("arg_1", ["n_a"], "n_x", "src_2")], // same NAME, other source — excluded
sessions: {},
sources: {},
} as unknown as Parameters<typeof sourceDigest>[0];
assert.equal(sourceDigest(graph, "src_9"), "", "a source owning nothing runs bare — no digest block");
const digest = sourceDigest(graph, "src_1");
assert.match(digest, /THE GRAPH SO FAR/);
assert.match(digest, /n_a \| Self-ownership \| statement of Self-ownership/);
assert.doesNotMatch(digest, /Foreign key/, "other sources' material never leaks into the digest");
assert.doesNotMatch(digest, /DERIVATIONS ALREADY RECORDED/, "no same-source chains → no chain block");
});
it("a seeded digest joins a fresh stretch: the spanning chain + connective ride the audit and merge, billed as the spine stage", async () => {
it("a seeded digest rides the extractor's view: mixed-namespace chains reference existing ids VERBATIM", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// A PREVIOUS stretch's residue: two nodes already in the graph,
// provenance on this chat — the spine's digest.
const seed = (id: string, name: string): GraphNode => ({
id,
name,
@@ -1948,127 +1827,221 @@ describe("the spine stage (cross-stretch assembly)", () => {
provenance: [{ chatId, messageId: "m0" }],
});
ledgers.appendNode(seed("n_a", "Self-ownership"));
ledgers.appendNode(seed("n_b", "Scarcity"));
const FINDINGS_B: SessionFindings = {
propositions: [{ id: "p1", name: "First-use survives", statement: "c", aliases: [] }],
chains: [],
propositions: [{ id: "p1", name: "Appropriation axiom", statement: "c", aliases: [] }],
chains: [{ premises: ["n_a", "p1"], conclusion: "p1", }].slice(0, 0), // chains live in the engine below
};
const spineViews: string[] = [];
let spineCalls = 0;
const phases: string[] = [];
type LiveUsage = NonNullable<ReturnType<typeof extractionStatuses>[number]["usage"]>;
const usages: LiveUsage[] = [];
const spineEngine = fauxEngine([
const views: string[] = [];
const extractor = fauxEngine([
(context) => {
spineCalls++;
spineViews.push(capturedView(context));
phases.push(extractionStatuses().find((r) => r.chatId === chatId)?.phase ?? "?");
const submit: SpineSubmit = {
propositions: [{ id: "s1", name: "Appropriation axiom", statement: "d", aliases: [] }],
chains: [{ premises: ["n_a", "p1"], conclusion: "s1" }],
views.push(capturedView(context));
const submit = {
propositions: FINDINGS_B.propositions,
chains: [{ premises: ["n_a", "p1"], conclusion: "p1b" }],
};
return fauxAssistantMessage([fauxToolCall("submit_spine", submit as unknown as ToolCall["arguments"], { id: "sp1" })], { stopReason: "toolUse" });
// conclusion p1b must exist — real submit:
submit.propositions.push({ id: "p1b", name: "Property rights", statement: "d", aliases: [] });
return fauxAssistantMessage([fauxToolCall("submit_findings", submit as unknown as ToolCall["arguments"], { id: "f1" })], { stopReason: "toolUse" });
},
]);
const reviewMessage = () =>
fauxAssistantMessage([fauxToolCall("submit_review", { links: [] } as unknown as ToolCall["arguments"], { id: "rv" })], { stopReason: "toolUse" });
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS_B),
spine: spineEngine,
auditor: fauxEngine([
() => {
usages.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage ?? {});
return reviewMessage();
},
]),
});
const stats = await pipeline.runExtraction(chatId);
// The spine ran once, under its own phase, seeing the digest and the
// fresh stretch rendered together (existing ids verbatim).
assert.equal(spineCalls, 1);
assert.deepEqual(phases, ["assembling"]);
assert.match(spineViews[0]!, /THE GRAPH SO FAR/);
assert.match(spineViews[0]!, /n_a \| Self-ownership/);
assert.match(spineViews[0]!, /p1 \| First-use survives/);
// 2 nodes this run (the fresh proposition + the connective), 1 spanning chain.
assert.deepEqual(stats, { nodes: 2, chains: 1 });
const stored = ledgers.readChains();
assert.equal(stored.length, 1);
const nodes = ledgers.readNodes();
const byName = new Map(nodes.map((n) => [n.name, n.id]));
// Mixed namespace resolved: the existing n_a passes through
// VERBATIM; fresh p1/s1 resolve through the job-local map.
assert.deepEqual(stored[0]!.premises, ["n_a", byName.get("First-use survives")]);
assert.equal(stored[0]!.conclusion, byName.get("Appropriation axiom"));
// Billing: the spine is its own stage — one extractor step, one
// spine step, one auditor step, live in the registry.
assert.equal(usages[0]!.extractor?.steps.length, 1);
assert.equal(usages[0]!.spine?.steps.length, 1);
});
it("a non-spanning spine submit is REFUSED with the problem and retried: only the spanning retry merges", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const seed = (id: string, name: string): GraphNode => ({
id,
name,
statement: `statement of ${name}`,
aliases: [],
createdAt: "2026-09-30T00:00:00.000Z",
provenance: [{ chatId, messageId: "m0" }],
});
ledgers.appendNode(seed("n_a", "Self-ownership"));
ledgers.appendNode(seed("n_b", "Scarcity"));
const FINDINGS_B: SessionFindings = {
propositions: [{ id: "p1", name: "First-use survives", statement: "c", aliases: [] }],
chains: [],
};
const spineEngine = fauxEngine([
// All-existing chain: the graph already had its chance — refused
// with the problem, error-back to the model.
() =>
fauxAssistantMessage(
[
fauxToolCall(
"submit_spine",
{ propositions: [], chains: [{ premises: ["n_a"], conclusion: "n_b" }] } as unknown as ToolCall["arguments"],
{ id: "sp-bad" },
),
],
{ stopReason: "toolUse" },
),
// The retry spans: existing n_a + fresh p1 ⟹ existing n_b.
() =>
fauxAssistantMessage(
[
fauxToolCall(
"submit_spine",
{ propositions: [], chains: [{ premises: ["n_a", "p1"], conclusion: "n_b" }] } as unknown as ToolCall["arguments"],
{ id: "sp-good" },
),
],
{ stopReason: "toolUse" },
),
]);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS_B),
spine: spineEngine,
extractor,
auditor: submitModel("submit_review", { links: [] }),
});
const stats = await pipeline.runExtraction(chatId);
// The refused attempt left nothing; the retry's spanning chain merged.
assert.deepEqual(stats, { nodes: 1, chains: 1 });
// The extractor's view LED with the digest (existing id verbatim).
assert.match(views[0]!, /THE GRAPH SO FAR/);
assert.match(views[0]!, /n_a \| Self-ownership/);
// Mixed namespace resolved: n_a passes through verbatim; p1/p1b
// resolve through the job-local map.
assert.deepEqual(stats, { nodes: 2, chains: 1 });
const stored = ledgers.readChains();
assert.equal(stored.length, 1);
const firstUse = ledgers.readNodes().find((n) => n.name === "First-use survives")!;
assert.deepEqual(stored[0]!.premises, ["n_a", firstUse.id]);
assert.equal(stored[0]!.conclusion, "n_b");
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
assert.deepEqual(stored[0]!.premises, ["n_a", byName.get("Appropriation axiom")]);
assert.equal(stored[0]!.conclusion, byName.get("Property rights"));
});
it("a chain endpoint that resolves NOWHERE is refused with the problem and the retry merges (error-back)", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const badThenGood = fauxEngine([
() =>
fauxAssistantMessage(
[
fauxToolCall(
"submit_findings",
{ propositions: [], chains: [{ premises: ["n_nope"], conclusion: "also_nope" }] } as unknown as ToolCall["arguments"],
{ id: "bad" },
),
],
{ stopReason: "toolUse" },
),
() =>
fauxAssistantMessage(
[
fauxToolCall(
"submit_findings",
{
propositions: [{ id: "p1", name: "Axiom", statement: "s", aliases: [] }],
chains: [],
} as unknown as ToolCall["arguments"],
{ id: "good" },
),
],
{ stopReason: "toolUse" },
),
]);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: badThenGood,
auditor: submitModel("submit_review", { links: [] }),
});
const stats = await pipeline.runExtraction(chatId);
assert.deepEqual(stats, { nodes: 1, chains: 0 }, "the refused attempt left nothing; the retry merged");
});
it("batches serialize: chunk 2's extractor starts only after chunk 1's audit ran (digest order, owner 2026-09-30 evening)", async () => {
const { chatId } = chunkyChat(); // 2 deterministic batches at the 4096 override
const ledgers = memoryLedgers();
const events: string[] = [];
let extractCalls = 0;
let auditCalls = 0;
// One step per engine call (faux engines consume steps): two batches.
const extractStep = (): FauxResponseStep => () => {
extractCalls++;
events.push(`extract${extractCalls}`);
return fauxAssistantMessage(
[fauxToolCall("submit_findings", FINDINGS as unknown as ToolCall["arguments"], { id: `f${extractCalls}` })],
{ stopReason: "toolUse" },
);
};
const auditStep = (): FauxResponseStep => () => {
auditCalls++;
events.push(`audit${auditCalls}`);
return fauxAssistantMessage(
[fauxToolCall("submit_review", { links: [] } as unknown as ToolCall["arguments"], { id: `r${auditCalls}` })],
{ stopReason: "toolUse" },
);
};
const extractor = fauxEngine([extractStep(), extractStep()]);
const auditor = fauxEngine([auditStep(), auditStep()]);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor,
auditor,
getChunkTokens: () => 4096, // two batches over the chunky fixture (factory default doubled to 8192, 2026-09-30)
});
const stats = await pipeline.runExtraction(chatId);
assert.deepEqual(events, ["extract1", "audit1", "extract2", "audit2"], "strict per-source order: extract → audit → merge → NEXT extract");
assert.equal(stats.nodes, 4, "both batches merged (2 propositions each)");
});
});
describe("chain simplification (the merger's maintenance pass, owner 2026-09-30 evening)", () => {
const chain = (id: string, premises: string[], conclusion: string, createdAt: string): GraphChain => ({
id,
premises,
conclusion,
createdAt,
provenance: [{ chatId: "src_1", messageId: "" }],
});
it("the owner example: [A,B]⇒X + [X,C]⇒Y make [A,B,C]⇒Y redundant (ancestry-cover)", () => {
const kept = simplifyChains([
chain("arg_3", ["n_a", "n_b", "n_c"], "n_y", "2026-09-30T03:00:00.000Z"),
chain("arg_1", ["n_a", "n_b"], "n_x", "2026-09-30T01:00:00.000Z"),
chain("arg_2", ["n_x", "n_c"], "n_y", "2026-09-30T02:00:00.000Z"),
]);
assert.deepEqual(kept.map((c) => c.id).sort(), ["arg_1", "arg_2"]);
});
it("prefer-fewest-premises: a strict premise-superset of a kept chain drops", () => {
const kept = simplifyChains([
chain("arg_1", ["n_a"], "n_z", "2026-09-30T01:00:00.000Z"),
chain("arg_2", ["n_a", "n_b"], "n_z", "2026-09-30T02:00:00.000Z"),
]);
assert.deepEqual(kept.map((c) => c.id), ["arg_1"]);
});
it("genuinely different routes to one conclusion stand; a superset route yields to the lean one", () => {
const kept = simplifyChains([
chain("arg_1", ["n_a"], "n_y", "2026-09-30T01:00:00.000Z"),
chain("arg_2", ["n_b"], "n_y", "2026-09-30T02:00:00.000Z"),
chain("arg_3", ["n_c", "n_d"], "n_y", "2026-09-30T03:00:00.000Z"),
chain("arg_4", ["n_a", "n_c"], "n_y", "2026-09-30T04:00:00.000Z"),
]);
// arg_3's premises are covered by NO kept chain — an independent
// route, it stands. arg_4 is a strict premise-superset of arg_1's
// route (n_a already suffices) — the lean derivation wins.
assert.deepEqual(kept.map((c) => c.id).sort(), ["arg_1", "arg_2", "arg_3"]);
});
it("ancestry reaches transitively: [W]⇒A chains through [A,B]⇒X", () => {
const kept = simplifyChains([
chain("arg_1", ["n_w"], "n_a", "2026-09-30T01:00:00.000Z"),
chain("arg_2", ["n_a", "n_b"], "n_x", "2026-09-30T02:00:00.000Z"),
chain("arg_3", ["n_x", "n_c"], "n_y", "2026-09-30T03:00:00.000Z"),
chain("arg_4", ["n_w", "n_b", "n_c"], "n_y", "2026-09-30T04:00:00.000Z"),
]);
assert.deepEqual(kept.map((c) => c.id).sort(), ["arg_1", "arg_2", "arg_3"]);
});
it("same-key provenance lines drop TOGETHER (a key is kept or dropped as a whole)", () => {
const kept = simplifyChains([
chain("arg_1", ["n_a"], "n_z", "2026-09-30T01:00:00.000Z"),
{ ...chain("arg_1", ["n_a"], "n_z", "2026-09-30T01:00:00.000Z"), provenance: [{ chatId: "src_2", messageId: "" }] },
chain("arg_2", ["n_a", "n_b"], "n_z", "2026-09-30T02:00:00.000Z"),
{ ...chain("arg_2", ["n_a", "n_b"], "n_z", "2026-09-30T02:00:00.000Z"), provenance: [{ chatId: "src_2", messageId: "" }] },
]);
assert.deepEqual(kept.map((c) => c.id), ["arg_1", "arg_1"], "the superset key's every line went, the kept key's every line stayed");
});
it("the merge applies it live: a redundant arrival is skipped silently, stats count appends and drops", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// Seed [A,B]⇒X and [X,C]⇒Y as an earlier source's chains.
const seedNode = (id: string, name: string): GraphNode => ({
id,
name,
statement: `statement of ${name}`,
aliases: [],
createdAt: "2026-09-30T00:00:00.000Z",
provenance: [{ chatId, messageId: "m0" }],
});
for (const [id, name] of [
["n_a", "A"],
["n_b", "B"],
["n_c", "C"],
["n_x", "X"],
["n_y", "Y"],
] as const)
ledgers.appendNode(seedNode(id, name));
ledgers.appendChain(chain("arg_1", ["n_a", "n_b"], "n_x", "2026-09-30T01:00:00.000Z"));
ledgers.appendChain(chain("arg_2", ["n_x", "n_c"], "n_y", "2026-09-30T02:00:00.000Z"));
// The chat re-asserts the full derivation [A,B,C]⇒Y (all-existing
// ids, digest references) — redundant given what stands.
const redundant: SessionFindings = {
propositions: [],
chains: [{ premises: ["n_a", "n_b", "n_c"], conclusion: "n_y" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", redundant),
auditor: submitModel("submit_review", { links: [] }),
});
const stats = await pipeline.runExtraction(chatId);
assert.equal(stats.chains, 1, "the append happened (stats count activity)");
assert.equal(stats.droppedChains, 1, "the simplification pass then removed it (maintenance)");
const stored = ledgers.readChains();
assert.equal(stored.length, 2, "the ledger is back to the two lean chains");
});
});
-1
View File
@@ -54,7 +54,6 @@ describe("displayCostTotals (naming-free cost fold)", () => {
const costs = {
stages: {
extraction: stage(1.25, 1),
spine: stage(0), // spine channel exists since 2026-09-30 — zero spend here
audit: stage(0.75),
naming: stage(9.99, 2), // dead channel — must never reach the display
},
+1 -1
View File
@@ -49,7 +49,7 @@ describe("parseAgentSettingsBody", () => {
});
const bad = parseAgentSettingsBody({ models: { teacher: {} } });
assert.equal(bad.ok, false);
if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, spine, auditor, partitioner, problemGenerator, grader\)/);
if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor, partitioner, problemGenerator, grader\)/);
const malformed = parseAgentSettingsBody({ models: { researcher: { model: { provider: 1, id: "x" } } } });
assert.equal(malformed.ok, false);
});
+2 -3
View File
@@ -23,7 +23,7 @@ describe("FACTORY_SETTINGS (the compiled factory layer)", () => {
assert.deepEqual(FACTORY_SETTINGS.general, { autoUpdate: true });
// wave 7 collapse: the master switch ONLY + the extraction-UX
// redesign's chunk budget (2024-09-24); default doubled 8192 with
// the spine stage (owner, 2026-09-30)
// the digest-aware redesign (owner, 2026-09-30)
assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, extractionChunkTokens: 8192 });
const a = FACTORY_SETTINGS.appearance;
assert.equal(a.mode, "dark");
@@ -203,13 +203,12 @@ describe("sanitizeUserSettings", () => {
assert.deepEqual(out.agent?.tools, { quiz: false, demo: true });
});
it("BACKGROUND_MODEL_ROLES matches the nine background roles (spine added 2026-09-30; practice trio 2026-09-29)", () => {
it("BACKGROUND_MODEL_ROLES matches the eight background roles (practice trio 2026-09-29)", () => {
assert.deepEqual([...BACKGROUND_MODEL_ROLES], [
"researcher",
"demoBuilder",
"visualMaker",
"extractor",
"spine",
"auditor",
"partitioner",
"problemGenerator",
-1
View File
@@ -127,7 +127,6 @@ const catalog = (strongProvider: string | null, ...keyedProviders: string[]): Mo
demoBuilder: null,
visualMaker: null,
extractor: null,
spine: null,
auditor: null,
partitioner: null,
problemGenerator: null,