extraction: the spine stage — cross-stretch assembly between extract and audit
Per-batch pipeline is now extract → spine → audit → merge (owner order, 2026-09-30), the spine inside the serialized audit section so it reads the graph exactly as the following audit will. Measured motive: 0 of 133 chains in the live corpus crossed a stretch boundary — no stage minted cross-stretch chains. - src/agent/subagents/spine.ts: SESSION/NOTES spine preambles + shared SPINE_CORE (span doctrine; extraction doctrines carried over), the submit_spine TypeBox schema, validateSpine (mechanical span law: every chain touches >=1 fresh endpoint AND >=1 existing digest endpoint; unknown ids, self-reference, and id collisions are loud), the digest prompt builder, and the runner (strong tier, retry-only). - extraction.ts: 'assembling' phase, spine stage in the per-need gate, usage/billing stage, assemble() inside withAuditLock for both front doors; first stretch of a source skips the call (empty digest, no spend); the audit sees COMBINED propositions (chunk + connectives), never chains; merger receives the combined findings. - core/graph.ts mergeFindings: chain endpoints resolve through the job-local map OR pass through verbatim as existing n_ ids (the spine's mixed namespace). - EXTRACTION_CORE: steps-of-reasoning selection bar (examples, notation glosses, and restatements that no argument needs are not steps). - Factory chunk budget 4096 -> 8192 (fewer seams, more local context). - healStringifiedArrays moved to the shared submit seam (submit-agent.ts). - Cost panel: Assembly row; usage records carry a spine stage (optional on legacy records). - Tests +5: prompt pins, span-law matrix, digest rendering, seeded end-to-end flow (spanning chain + connective, verbatim n_ endpoint, spine billed as its own stage), non-spanning refused + retried.
This commit is contained in:
+336
-13
@@ -52,6 +52,15 @@ import {
|
||||
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 { 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";
|
||||
@@ -275,6 +284,15 @@ 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 () => {
|
||||
@@ -384,7 +402,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chains: FINDINGS.chains,
|
||||
});
|
||||
|
||||
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
|
||||
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, spine: spineModel(), auditor: submitModel("submit_review", verdict) });
|
||||
const stats = await pipeline.runExtraction(chatId);
|
||||
assert.deepEqual(stats, { nodes: 2, chains: 1 });
|
||||
});
|
||||
@@ -400,6 +418,7 @@ 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 });
|
||||
@@ -428,6 +447,7 @@ 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);
|
||||
@@ -476,7 +496,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"]); // extractor + auditor are the cheap tier
|
||||
assert.deepEqual(err.envelope.missing, ["cheap", "strong"]); // extractor + auditor cheap, spine strong
|
||||
assert.equal(err.envelope.code, "models_not_configured");
|
||||
return true;
|
||||
});
|
||||
@@ -494,6 +514,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor,
|
||||
spine: spineModel(),
|
||||
auditor,
|
||||
providerKeyMissing: (provider, tier) => {
|
||||
seen.push([provider, tier]);
|
||||
@@ -521,14 +542,15 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: fauxEngine([]),
|
||||
spine: spineModel(),
|
||||
auditor: fauxEngine([]),
|
||||
providerKeyMissing: (provider, tier) => (calls++ === 0 ? null : providerKeyMissing(provider, [tier])),
|
||||
providerKeyMissing: (provider, tier) => (calls++ < 2 ? null : providerKeyMissing(provider, [tier])),
|
||||
});
|
||||
await assert.rejects(pipeline.runExtraction(chatId), (err: unknown) => {
|
||||
assert.ok(err instanceof ProviderKeyMissingError, "the auditor's refusal surfaces after the extractor passed");
|
||||
assert.ok(err instanceof ProviderKeyMissingError, "the auditor's refusal surfaces after extractor + spine passed");
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls, 2, "both stages were checked, in order");
|
||||
assert.equal(calls, 3, "all three stages were checked, in order");
|
||||
assert.equal(ledgers.readCoverage().length, 0);
|
||||
});
|
||||
|
||||
@@ -541,6 +563,7 @@ 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);
|
||||
@@ -566,17 +589,17 @@ 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: [] });
|
||||
},
|
||||
});
|
||||
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 (the
|
||||
// labeler is fire-and-forget garnish — extractor + auditor are the
|
||||
// pinned order).
|
||||
assert.equal(consulted[0], "extractor");
|
||||
assert.equal(consulted[1], "auditor");
|
||||
// The stages were resolved through the chain, in pipeline order
|
||||
// (extractor → spine → auditor; the labeler is fire-and-forget
|
||||
// garnish for any other role).
|
||||
assert.deepEqual(consulted.slice(0, 3), ["extractor", "spine", "auditor"]);
|
||||
});
|
||||
|
||||
it("direct test values still WIN over engineFor (the priority pin)", async () => {
|
||||
@@ -587,6 +610,7 @@ 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) => {
|
||||
@@ -605,6 +629,7 @@ 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
|
||||
@@ -618,6 +643,7 @@ 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 });
|
||||
@@ -639,8 +665,15 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const mixed = createExtractionPipeline({
|
||||
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).
|
||||
getChunkTokens: () => 4096,
|
||||
engineFor: (role) =>
|
||||
role === "extractor" ? fauxEngine([countingStep]) : submitModel("submit_review", { links: [] }),
|
||||
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");
|
||||
assert.equal(invocations, 2);
|
||||
@@ -675,6 +708,7 @@ 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);
|
||||
@@ -701,6 +735,7 @@ 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;
|
||||
}),
|
||||
@@ -718,6 +753,7 @@ 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");
|
||||
@@ -755,6 +791,7 @@ 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);
|
||||
@@ -765,6 +802,7 @@ 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;
|
||||
}),
|
||||
@@ -787,6 +825,7 @@ 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 });
|
||||
@@ -814,6 +853,7 @@ 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),
|
||||
),
|
||||
@@ -852,6 +892,7 @@ 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);
|
||||
@@ -902,6 +943,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: gatedExtractor,
|
||||
spine: spineModel(),
|
||||
auditor: silentModel(),
|
||||
});
|
||||
|
||||
@@ -938,6 +980,7 @@ 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
|
||||
@@ -947,6 +990,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: memoryLedgers(),
|
||||
extractor: silentModel(),
|
||||
spine: spineModel(),
|
||||
auditor: silentModel(),
|
||||
});
|
||||
|
||||
@@ -973,6 +1017,7 @@ 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/);
|
||||
@@ -1042,6 +1087,7 @@ 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 });
|
||||
@@ -1077,6 +1123,7 @@ 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;
|
||||
@@ -1097,6 +1144,7 @@ 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 });
|
||||
@@ -1141,6 +1189,7 @@ 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;
|
||||
@@ -1159,6 +1208,7 @@ 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 });
|
||||
@@ -1175,6 +1225,7 @@ 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 });
|
||||
@@ -1223,6 +1274,7 @@ 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 });
|
||||
@@ -1282,7 +1334,9 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
engineFor: (role) =>
|
||||
role === "extractor"
|
||||
? extractorStep()
|
||||
: submitModel("submit_review", { links: [] }),
|
||||
: role === "spine"
|
||||
? submitModel("submit_spine", { propositions: [], chains: [] })
|
||||
: submitModel("submit_review", { links: [] }),
|
||||
getChunkTokens: () => 20, // 80 chars/line-groups → several chunks
|
||||
});
|
||||
const stats = await pipeline.importNotes({ filePath: "/vault/big.md", text });
|
||||
@@ -1335,6 +1389,7 @@ 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 });
|
||||
@@ -1365,6 +1420,7 @@ 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 });
|
||||
@@ -1445,7 +1501,11 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
const pipeline = createExtractionPipeline({
|
||||
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).
|
||||
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);
|
||||
@@ -1493,6 +1553,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
const pipeline = createExtractionPipeline({
|
||||
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).
|
||||
getChunkTokens: () => 4096,
|
||||
// Batch 1 submits and merges; batch 2's extractor call HANGS
|
||||
// until the abort fires — the dock's abort button, mid-run.
|
||||
extractor: fauxEngine([
|
||||
@@ -1507,6 +1570,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
|
||||
}),
|
||||
]),
|
||||
spine: spineModel(),
|
||||
auditor: fauxEngine([passThroughAuditStep()]),
|
||||
});
|
||||
const runPromise = pipeline.runExtraction(chatId);
|
||||
@@ -1537,6 +1601,7 @@ 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);
|
||||
@@ -1561,6 +1626,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: fauxEngine([findingsStep()]),
|
||||
spine: spineModel(),
|
||||
auditor: fauxEngine([passThroughAuditStep()]),
|
||||
});
|
||||
await pipeline.runExtraction(chatId);
|
||||
@@ -1596,6 +1662,7 @@ 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);
|
||||
@@ -1697,10 +1764,19 @@ 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]) : fauxEngine([slowAudit])),
|
||||
engineFor: (role) =>
|
||||
role === "extractor"
|
||||
? fauxEngine([slowExtract])
|
||||
: role === "spine"
|
||||
? fauxEngine([() => spineMessage])
|
||||
: fauxEngine([slowAudit]),
|
||||
getChunkTokens: () => 20,
|
||||
});
|
||||
const stats = await pipeline.importNotes({ filePath: "/vault/parallel-2.md", text: wideText() });
|
||||
@@ -1726,6 +1802,7 @@ 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);
|
||||
@@ -1749,3 +1826,249 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
||||
assert.equal(chatStore.getChat(chatId), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the spine stage (owner, 2026-09-30): extract → spine → audit → merge ────
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("a seeded digest joins a fresh stretch: the spanning chain + connective ride the audit and merge, billed as the spine stage", 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,
|
||||
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 spineViews: string[] = [];
|
||||
let spineCalls = 0;
|
||||
const phases: string[] = [];
|
||||
type LiveUsage = NonNullable<ReturnType<typeof extractionStatuses>[number]["usage"]>;
|
||||
const usages: LiveUsage[] = [];
|
||||
const spineEngine = 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" }],
|
||||
};
|
||||
return fauxAssistantMessage([fauxToolCall("submit_spine", submit as unknown as ToolCall["arguments"], { id: "sp1" })], { 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,
|
||||
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 });
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user