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:
+286
-313
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user