diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 3474bf8..2dd101b 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -28,7 +28,6 @@ import type { GraphStore } from "../../core/ports.ts"; import { emit } from "../../core/events.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"; -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"; @@ -42,10 +41,15 @@ import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts"; * 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, walked in stretches) → submits NEW core - * ideas + reasoning chains (owner redesign, - * 2026-09-30). DIGEST-AWARE (2026-09-30 evening): + * rendered as the teacher's prose — each passage + * labeled with its ACTUAL message id, "TEACHER [id]", + * owner 2026-10-13 — or a notes file verbatim, walked + * in stretches) → submits NEW core ideas + reasoning + * chains (owner redesign, 2026-09-30); a session + * submit additionally carries each proposition's + * messageId — the anchor the merger stamps into the + * node's message-granular provenance (2026-10-13). + * 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 @@ -104,14 +108,16 @@ import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts"; /** * The extraction view: the session as TEACHER PROSE ONLY (owner ruling, - * 2026-09-28 — supersedes the 2026-09-27 whole-session markdown view): - * plain prose paragraphs under the session title. The shared markdown - * engine (core/session-markdown.ts) keeps the full session format for - * the future user-facing export; the extractor no longer uses it. + * 2026-09-28 — supersedes the 2026-09-27 whole-session markdown view), + * each passage labeled with its actual message id (2026-10-13). The + * shared markdown engine (core/session-markdown.ts) keeps the full session + * format for the future user-facing export; the extractor no longer uses + * it. */ /** The whole-record view (used by callers that render a full session; - * the pipeline itself batches). TEACHER PROSE ONLY (owner, 2026-09-28). + * the pipeline itself batches). TEACHER PROSE ONLY (owner, 2026-09-28), + * passages labeled with their message ids (2026-10-13). */ export function transcriptView(record: ChatRecord): string { return proseView(record.messages, record.title); @@ -119,13 +125,17 @@ export function transcriptView(record: ChatRecord): string { /** The extraction view under the teacher-prose ruling (owner, 2026-09-28): * ONLY the teacher agent's text blocks — no learner messages, no - * thinking, no tool calls or results, no quiz blocks. Plain paragraphs - * under the session title; the shared markdown engine stays reserved for - * the future user-facing export. */ + * thinking, no tool calls or results, no quiz blocks — each labeled with + * its ACTUAL message id (owner, 2026-10-13): the view lines ARE + * core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact + * lines the coverage fingerprints hash, so the extractor can anchor + * every proposition to the passage it came from (the session submit's + * required messageId). The shared markdown engine stays reserved for the + * future user-facing export. */ function proseView(messages: ReadonlyArray, title?: string): string { - const prose = teacherProseBlocks(messages); + const lines = messages.flatMap((message) => messageViewLines(message, message.id)); const head = title && title.length > 0 ? `# ${title}\n\n` : ""; - return head + prose.join("\n\n"); + return head + lines.join("\n\n"); } // ─── the extractor ────────────────────────────────────────────────────────── @@ -176,21 +186,45 @@ const chainSchema = Type.Object({ // here + a ??-fill at the submit seam (fillFindings/fillAudit below) — same // contract on the wire (omission tolerated, empty never means "invalid"), // no zod left in the agent band. -const findingsProps = { +const propositionsDescription = + "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."; +const chainsDescription = + "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."; + +const findingsSchema = Type.Object({ propositions: Type.Array(Type.Object(propositionProps), { - 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.", + description: propositionsDescription, }), chains: Type.Array(chainSchema, { + description: chainsDescription, + }), +}); + +// THE SESSION EXTENSION (owner, 2026-10-13): every session proposition +// carries a REQUIRED messageId — the anchor of the passage it came from, +// copied from the view's "TEACHER [id]" label (validated at the submit +// seam against the CURRENT batch). The NOTES door has no messages: its +// schema stays the base shape above, prompt and wire unchanged. +const sessionPropositionProps = { + ...propositionProps, + messageId: Type.String({ description: - "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.", + "The message id of the ONE passage in this stretch that most directly states or explains this " + + 'proposition — copy it EXACTLY from that passage\'s "TEACHER [id]" label.', }), }; -const findingsSchema = Type.Object(findingsProps); +const sessionFindingsSchema = Type.Object({ + propositions: Type.Array(Type.Object(sessionPropositionProps), { + description: propositionsDescription, + }), + chains: Type.Array(chainSchema, { + description: chainsDescription, + }), +}); /** What the extractor SUBMITS (the redesign's wire shape) — propositions * + chains. fillFindings normalizes it; validateFindingsChains checks @@ -199,6 +233,10 @@ const findingsSchema = Type.Object(findingsProps); * 2026-09-30: no edge translation at any seam). */ type FindingsSubmit = Static; +/** The SESSION wire — the notes shape plus the REQUIRED messageId anchor + * on every proposition (owner, 2026-10-13). */ +type SessionFindingsSubmit = Static; + // VERDICTS-ONLY (owner, 2026-09-28): the auditor never alters the // extractor's findings, so it does not restate them — links are its // ENTIRE deliverable (owner, 2026-09-28 evening: the edges argument is @@ -217,9 +255,11 @@ const auditSchema = Type.Object({ ), }); -// The notes front door's shapes are IDENTICAL to the session shapes now -// (the only difference was quiz attribution, which no longer exists): -// one schema, one findings type, both front doors. +// The notes front door's wire shape is the session shape MINUS the +// messageId anchor (owner, 2026-10-13: only the SESSION door demands +// passage anchors — notes have no messages, so their prompt and schema +// stay exactly as they were): one base findings type for both doors, +// the anchor riding as an optional field. type NotesFindings = SessionFindings; type NotesAuditResult = AuditVerdicts; @@ -233,13 +273,30 @@ type NotesAuditResult = AuditVerdicts; // a pile of nodes with no structure). Only the per-item aliases stay // optional with a ??-fill. -function fillFindings(raw: FindingsSubmit): FindingsSubmit { +function fillFindings; chains: unknown }>( + raw: T, +): T { return { + ...raw, propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), - chains: raw.chains, }; } +/** The notes fill: the shared alias ??-fill PLUS the door's boundary — a + * stray messageId (the notes schema has none; extra properties pass + * schema validation) is stripped so notes provenance stays chat-level + * (the file is the anchor, owner 2026-09-13). */ +function fillNotesFindings(raw: FindingsSubmit): FindingsSubmit { + return fillFindings({ + ...raw, + propositions: raw.propositions.map((p) => { + const stripped = { ...p } as typeof p & { messageId?: unknown }; + delete stripped.messageId; + return stripped; + }), + }); +} + /** 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 @@ -266,12 +323,38 @@ function validateFindingsChains(value: FindingsSubmit, existingIds: ReadonlySet< return null; } +/** The SESSION anchor law (owner, 2026-10-13): every proposition names a + * passage of the CURRENT batch — nonempty, and a teacher-prose message + * id present in the stretch this extractor is reading (the same ids the + * view's TEACHER [id] labels carry). Refused anchors ride the existing + * error-back channel: no merge, no coverage checkpoint, until a valid + * submit lands. The boundary is strict on purpose — an id from a LATER + * or already-covered batch, another chat, or a learner/tool message is + * refused even when it is a real message id of this chat. */ +function validateSessionAnchors(value: SessionFindingsSubmit, batchIds: ReadonlySet): string | null { + for (const p of value.propositions) { + if (p.messageId.trim().length === 0) { + return `propositions: "${p.id}" is missing its messageId — copy the message id from the brackets of the passage (TEACHER [id]) that most directly states or explains it.`; + } + if (!batchIds.has(p.messageId)) { + return `propositions: "${p.id}" names messageId "${p.messageId}", which is not a passage of THIS stretch — copy the id from the brackets of one of this stretch's own TEACHER [id] passages.`; + } + } + return null; +} + /** (chains-primary, owner 2026-09-30) the wire IS the pipeline shape * now: propositions + chains, no translation — the stores keep the * chains and the derived edge view is recomputed on read. The fill * already ran inside runSubmitAgent; this only re-asserts the - * aliases-filled type. */ -const asSessionFindings = (raw: FindingsSubmit): SessionFindings => ({ + * aliases-filled type (and preserves the session submit's messageId + * anchor, 2026-10-13 — both doors lift through here). */ +const asSessionFindings = ( + raw: { + propositions: Array<{ id: string; name: string; statement: string; aliases?: string[]; messageId?: string }>; + chains: Array<{ premises: string[]; conclusion: string }>; + }, +): SessionFindings => ({ propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), chains: raw.chains, }); @@ -293,6 +376,14 @@ function fillAudit(raw: Static): AuditVerdicts { // describing Quiz/Recall-check/Question blocks was stale since the // 2026-09-28 teacher-prose ruling and is simply omitted.) +/** The session-only anchor instruction (owner, 2026-10-13, verbatim): + * passages arrive labeled "TEACHER [id]" and every proposition must + * name the ONE passage it came from — the anchor the merger stamps + * into message-granular provenance. Notes have no passages: their + * contextualization stays untouched. */ +export const SESSION_MESSAGE_ID_RULE = + "For each new proposition, copy the message ID of the passage that most directly states or explains it into messageId."; + export const SESSION_PREAMBLE = "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 " + @@ -337,7 +428,7 @@ reappear, by id, as a premise of later chains. A bare idea that no argument uses 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 EXTRACTOR_INSTRUCTIONS = `${SESSION_PREAMBLE}\n\n${EXTRACTION_CORE}\n\n${SESSION_MESSAGE_ID_RULE}`; export const NOTES_EXTRACTOR_INSTRUCTIONS = `${NOTES_PREAMBLE}\n\n${EXTRACTION_CORE}`; // ── the same-source digest (owner, 2026-09-30 evening) ── @@ -368,28 +459,33 @@ export function sourceDigest(graph: DerivedGraph, sourceId: string): string { return lines.join("\n"); } -/** The extractor over ONE session transcript (the chat front door). */ +/** The extractor over ONE session transcript (the chat front door). + * `batchIds` — the CURRENT batch's teacher-prose message ids — is the + * anchor boundary: every submitted proposition's messageId must name + * one of them (validateSessionAnchors at the submit seam). */ function runExtractor( engine: PiEngine | undefined, sourceId: string, prompt: string, existingIds: ReadonlySet, + batchIds: ReadonlySet, signal: AbortSignal | undefined, onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void, ): Promise { // 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({ + // over the MIXED namespace: local ids + existing graph ids, plus the + // session anchor law (messageId ∈ the current batch, 2026-10-13). + return runSubmitAgent({ role: "extractor", // Debug recorder key: the source id (the run's ledger key — the // chat's chatId for chat runs, the src id for notes). chatId: sourceId, toolName: "submit_findings", toolDescription: "Submit the session findings.", - parameters: findingsSchema, + parameters: sessionFindingsSchema, fill: fillFindings, - validate: (value) => validateFindingsChains(value, existingIds), + validate: (value) => validateSessionAnchors(value, batchIds) ?? validateFindingsChains(value, existingIds), systemPrompt: EXTRACTOR_INSTRUCTIONS, prompt, engine, @@ -401,7 +497,9 @@ function runExtractor( } /** The extractor over ONE source of raw personal notes (the format-agnostic - * front door): same single-submit shape, notes-mode prompt + schema. */ + * front door): same single-submit shape, notes-mode prompt + schema — + * the BASE wire (no messageId: notes have no passages, and a stray one + * is stripped at the fill seam). */ function runNotesExtractor( engine: PiEngine | undefined, sourceId: string, @@ -417,7 +515,7 @@ function runNotesExtractor( toolName: "submit_findings", toolDescription: "Submit the notes findings.", parameters: findingsSchema, - fill: fillFindings, + fill: fillNotesFindings, validate: (value) => validateFindingsChains(value, existingIds), systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS, prompt, @@ -793,7 +891,12 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti } } - async function extract(jobKey: string, sourceId: string, view: string): Promise { + async function extract( + jobKey: string, + sourceId: string, + view: string, + batchIds: ReadonlySet, + ): Promise { const signal = runningExtractions.get(jobKey)?.controller.signal; signal?.throwIfAborted(); // an abort during a sync gap ends the job here // THE DIGEST (owner, 2026-09-30 evening): the extractor continues a @@ -816,6 +919,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti sourceId, prompt, new Set(graph.nodes.map((n) => n.id)), + batchIds, signal, (usage, model, step, finishReason, tools) => { recordStep(jobKey, "extractor", callUsage(usage), model); @@ -838,12 +942,26 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // The auditor NEVER sees the source (owner 2026-09-27): findings // JSON + identity instructions, and the graph reached through - // exploration tools instead of an injected dump. + // exploration tools instead of an injected dump. The proposition + // projection omits messageId (2026-10-13): the auditor is + // IDENTITY-only and never sees the passages, so anchors are useless + // to it — they add cognitive load, not signal. const auditResult = await runAuditor( stageEngine("auditor", deps.auditor), sourceId, `Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted; the propositions only — the reasoning chains are the merger's business, owner 2026-09-30):\n\n` + - `${JSON.stringify({ propositions: findings.propositions }, null, 2)}\n\n` + + `${JSON.stringify( + { + propositions: findings.propositions.map((p) => ({ + id: p.id, + name: p.name, + statement: p.statement, + aliases: p.aliases, + })), + }, + null, + 2, + )}\n\n` + `The current knowledge graph has ${graph.nodes.length} node(s). Explore it with your tools and link what links.`, signal, (usage, model, step, finishReason, tools) => { @@ -1010,16 +1128,24 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti 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, + // 2026-09-28), each passage labeled with its ACTUAL message + // id (2026-10-13) — the view lines ARE the coverage + // fingerprints' messageViewLines ("TEACHER [id] text"), one + // shared seam. + const messages = batch.map((slot) => slot.message); + const view = proseView(messages, record.title); + // The batch's teacher-prose ids — the ONLY anchors this + // job's submit may name (validateSessionAnchors at the + // submit seam refuses anything else: ids from other + // batches — even real ones of this chat — other chats, + // and non-prose messages). + const batchIds = new Set( + messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id), ); setPhase(jobKey, "queued", "chat", undefined, part); const findings = await withExtractSlot(jobKey, async () => { setPhase(jobKey, "extracting", "chat", undefined, part); - return extract(jobKey, chatId, view); + return extract(jobKey, chatId, view, batchIds); }); // SERIALIZED AUDIT (owner, 2026-09-28): audit + merge are the // graph-mutating critical section — one job at a time, diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 01b9eeb..d578276 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -231,11 +231,20 @@ export interface ExtractedProposition { /** Verbatim phrasings the session used. */ aliases: string[]; /* Submit-schema slim (owner, 2026-09-28): `messages` and `evidence` - * are GONE. Provenance is chat-level (the run's source key); the - * verbatim-quote fields were never persisted and weak models burned - * their retries on them ("propositions.0: must be object" — the - * stringified-payload collapse). The extractor reports propositions - * + structure, nothing else. */ + * are GONE. The verbatim-quote fields were never persisted and weak + * models burned their retries on them ("propositions.0: must be + * object" — the stringified-payload collapse). The extractor reports + * propositions + structure, nothing else. */ + /** OPTIONAL, SESSION PATH ONLY (owner, 2026-10-13): the message id of + * the ONE teacher passage this proposition came from — the extractor + * copies it from the view's "TEACHER [id]" label (core/coverage.ts + * messageViewLines) and the submit seam validates it against the + * CURRENT batch. The merger stamps it into the node's provenance + * point {chatId, messageId} (new nodes AND link-appends); chains + * stay chat-level. Notes imports and legacy/core callers have none: + * undefined → messageId "" (chat-level), so NO persisted shape or + * format version changes (GraphNode / ProvenancePoint untouched). */ + messageId?: string; } /** One proposed argument (extractor redesign, 2026-09-30). Endpoints are diff --git a/src/core/graph.ts b/src/core/graph.ts index 7655901..39a1d41 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -11,8 +11,10 @@ * 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. + * cross-stretch itself). Session propositions carry + * their passage's messageId (2026-10-13) — the + * merger's provenance anchor. 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 @@ -109,11 +111,14 @@ export interface MergeSource { coverage?: CoverageEntry[]; } -/** The run's chat-level provenance point (submit-schema slim, 2026-09-28: - * propositions no longer carry message ids — the model never reported - * them reliably anyway — so every birth from a run shares one point). */ -function pointOf(chatId: string): ProvenancePoint[] { - return [{ chatId, messageId: "" }]; +/** The run's provenance point. Chat-level by default (submit-schema slim, + * 2026-09-28 — the run's source key is the whole point); since the anchor + * restore (owner, 2026-10-13) a SESSION birth may carry its passage's + * message id — the extractor-copied messageId, validated at the submit + * seam against the current batch. Chains, notes imports, and + * legacy/core callers lacking an anchor keep "" (chat-level). */ +function pointOf(chatId: string, messageId?: string): ProvenancePoint[] { + return [{ chatId, messageId: messageId ?? "" }]; } const samePoint = (a: ProvenancePoint, b: ProvenancePoint): boolean => @@ -166,7 +171,11 @@ export function mergeFindings( // extractor's findings verbatim; the auditor's links join here by id. for (const p of findings.propositions) { if (mapping.has(p.id)) continue; // duplicate id WITHIN this submit — first resolution stands - const points = pointOf(chatId); + // The anchor (2026-10-13): a session proposition may carry its + // passage's messageId — it rides BOTH the create and the link-append + // line (each re-encounter anchors to ITS passage); absent (notes, + // legacy/core callers) → "", chat-level as before. + const points = pointOf(chatId, p.messageId); const linkTo = linkOf.get(p.id); let graphNodeId: string; if (linkTo !== undefined && existingIds.has(linkTo)) { diff --git a/src/core/settings.ts b/src/core/settings.ts index 31eee28..4daece8 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -321,8 +321,11 @@ export interface GraphSettings { * carries the whole graph + instructions, so a smaller budget means * more, smaller runs — each paying that fixed overhead). * - * FACTORY DEFAULT 4096 (the pre-setting constant, config.ts's - * EXTRACTION.chunkTokens — vision-extraction-coverage.md, 2026-09-15). + * FACTORY DEFAULT 4096 (owner, 2026-10-13: halved back from the + * digest-era 8192 — the labeled passages + message anchors made + * smaller batches worth their overhead again; the pre-setting + * constant was 4096, vision-extraction-coverage.md, 2026-09-15). + * Only the FACTORY moves: an explicit user value always wins. * * FLOOR 500 (EXTRACTION_CHUNK_TOKENS_FLOOR, the implementer's call * per the owner's dispatch): every run pays the graph-carrying @@ -402,7 +405,7 @@ export const FACTORY_SETTINGS: FactorySettings = { sessionNameSerif: true, mdRendering: { headingWeight: 900, boldColor: null, italicColor: null }, }, - graph: { enabled: true, rendering: true, extractionChunkTokens: 8192 }, + graph: { enabled: true, rendering: true, extractionChunkTokens: 4096 }, compaction: { thresholdTokens: 150_000 }, }; diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 04aed81..ba22333 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -49,13 +49,14 @@ import { sourceDigest, EXTRACTION_CORE, SESSION_PREAMBLE, + SESSION_MESSAGE_ID_RULE, NOTES_PREAMBLE, EXTRACTOR_INSTRUCTIONS, NOTES_EXTRACTOR_INSTRUCTIONS, } from "../src/agent/subagents/extraction.ts"; -import { simplifyChains } from "../src/core/graph.ts"; +import { getGraph, 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 { coverageOf, coverageSlots, messageViewLines } from "../src/core/coverage.ts"; import { chatStore } from "../src/store/chats.ts"; import { subscribe } from "../src/core/events.ts"; import { PATHS } from "../config.ts"; @@ -232,7 +233,9 @@ after(() => { /** What the extractor SUBMITS for the fixture session (owner redesign, * 2026-09-30): propositions + REASONING CHAINS on the wire — one chain - * (p1 ⟹ p2), expanded to a follows-edge at the seam. */ + * (p1 ⟹ p2). Each proposition carries its passage anchor (2026-10-13): + * the fixture's teacher-prose messages are a1 (chain rule prose) and a2 + * (derivative prose) — p1 anchors to a2, p2 to a1. */ const FINDINGS = { propositions: [ { @@ -240,12 +243,14 @@ const FINDINGS = { name: "Derivative", statement: "The derivative measures how fast a function changes", aliases: [], + messageId: "a2", }, { id: "p2", name: "Chain rule", statement: "The chain rule differentiates composed functions by multiplying local rates.", aliases: [], + messageId: "a1", }, ], chains: [ @@ -331,10 +336,22 @@ describe("transcriptView (pure — teacher prose only, owner 2026-09-28)", () => const view = transcriptView(record); // The document header is the session title; the body is the - // teacher's text blocks as plain paragraphs. + // teacher's text blocks as labeled passages. assert.match(view, /^# Extraction fixture$/m); - assert.match(view, /The chain rule composes rates of change along a path\./); - assert.match(view, /The derivative measures how fast a function changes\./); + assert.match(view, /^TEACHER \[a1\] The chain rule composes rates of change along a path\.$/m); + assert.match(view, /^TEACHER \[a2\] The derivative measures how fast a function changes\.$/m); + + // MESSAGE-ID LABELS (owner, 2026-10-13): the passages carry their + // ACTUAL message ids, in exactly messageViewLines' shape — the same + // seam the coverage fingerprints hash. No label without prose: a3 + // (quiz only) and the learner messages are not passages. + assert.doesNotMatch(view, /TEACHER \[a3\]/); + assert.doesNotMatch(view, /TEACHER \[u1\]/); + assert.equal( + view.slice(view.indexOf("TEACHER")), + record.messages.flatMap((m) => messageViewLines(m, m.id)).join("\n\n"), + "the view body IS the messageViewLines of the transcript — one shared seam", + ); // EVERYTHING else is gone: learner text, quiz blocks, tool notes, // speaker headers — the ruling strips it all. @@ -363,7 +380,7 @@ describe("transcriptView (pure — teacher prose only, owner 2026-09-28)", () => ]), ].flat()); const view = transcriptView(chatStore.getChat(chat.id)!); - assert.match(view, /Prose survives\./); + assert.match(view, /^TEACHER \[a1\] Prose survives\.$/m, "the surviving passage is labeled with its real id"); assert.doesNotMatch(view, /research/); assert.doesNotMatch(view, /long brief/); assert.doesNotMatch(view, /### /); @@ -479,6 +496,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { linkedLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)), "the link-append unions provenance", ); + // ANCHORS (2026-10-13): both lines carry their proposition's passage + // id — p1's link-append anchors to a2 (its passage), p2's create to a1. + assert.ok( + linkedLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId && pt.messageId === "a2")), + "the link-append line carries p1's passage id", + ); + const created = ledgers.readNodes().find((n) => n.name === "Chain rule")!; + assert.deepEqual(created.provenance, [{ chatId, messageId: "a1" }], "the created node anchors to p2's passage"); // COVERAGE checkpointed per message (not a whole-chat checkpoint): // one entry per transcript message, fingerprinted content each — @@ -672,7 +697,8 @@ 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, 2026-09-30). + // default again — halved back from the digest-era 8192, owner + // 2026-10-13). getChunkTokens: () => 4096, engineFor: (role) => role === "extractor" @@ -725,7 +751,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const ledgers = memoryLedgers(); const single = (id: string, name: string, statement: string) => ({ - propositions: [{ id, name, statement, aliases: [], }], + propositions: [{ id, name, statement, aliases: [], messageId: "a1" }], chains: [], }); const findingsA = single("pa", "Alpha claim", "Alpha statement, full precision"); @@ -782,7 +808,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const ledgers = memoryLedgers(); const single = (id: string, name: string, statement: string) => ({ - propositions: [{ id, name, statement, aliases: [], }], + propositions: [{ id, name, statement, aliases: [], messageId: "a1" }], chains: [], }); @@ -1016,6 +1042,185 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { }); }); +// ─── message anchors (messageId — owner, 2026-10-13) ──────────────────────── + +describe("message anchors (session propositions carry their passage's id)", () => { + /** A single-proposition submit with an explicit anchor. */ + const anchored = (messageId: string) => ({ + propositions: [ + { id: "p1", name: "Anchor claim", statement: "Anchor claim, full precision.", aliases: [], messageId }, + ], + chains: [], + }); + + /** One scripted extractor turn: a submit_findings tool call. */ + const submitStep = (payload: unknown): FauxResponseStep => () => + fauxAssistantMessage( + [fauxToolCall("submit_findings", payload as ToolCall["arguments"], { id: "anchor-call" })], + { stopReason: "toolUse" }, + ); + + it("a submit MISSING messageId never passes: the schema rejects every turn — nothing merges, nothing checkpoints", async () => { + const { chatId } = fixtureChat(); + const ledgers = memoryLedgers(); + // The degraded shape: propositions without anchors, chains present — + // only the REQUIRED field is missing, and the schema refuses it on + // every turn until the budget is gone (the required-field doctrine, + // 2026-09-28, now covering the anchor too). + const bare = () => + fauxEngine( + Array.from({ length: 4 }, () => + submitStep({ + propositions: [{ id: "p1", name: "Anchor claim", statement: "Anchor claim, full precision.", aliases: [] }], + chains: [], + }), + ), + ); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + engineFor: (role) => (role === "extractor" ? bare() : submitModel("submit_review", { links: [] })), + }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 }); + assert.equal(ledgers.readNodes().length, 0, "nothing merged"); + assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the batch stays re-runnable"); + }); + + it("an EMPTY or UNKNOWN anchor rides the error-back channel: refused, the model re-anchors clean, then merges", async () => { + const { chatId } = fixtureChat(); + const ledgers = memoryLedgers(); + // Three bad anchors, all schema-valid (messageId is a string) — only + // the anchor law refuses them: empty, a LEARNER message of this very + // chat (real id, not a teacher passage), and pure fiction. + const extractor = fauxEngine([ + submitStep(anchored("")), + submitStep(anchored("u1")), + submitStep(anchored("made_up")), + submitStep(anchored("a2")), + ]); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + extractor, + auditor: submitModel("submit_review", { links: [] }), + }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 1, chains: 0 }, "the retry anchored and merged"); + const node = ledgers.readNodes()[0]!; + assert.deepEqual(node.provenance, [{ chatId, messageId: "a2" }], "the node anchors to its passage"); + }); + + it("the boundary is the CURRENT BATCH: a real teacher message id from another batch is refused, the in-batch retry merges", async () => { + const { chatId } = chunkyChat(); // deterministic batches [a1,a2,a3] + [a4,a5] at the 4096 budget + const ledgers = memoryLedgers(); + // Batch 1: "a5" is a REAL teacher-prose id of this very chat — but it + // lives in batch 2, so this stretch refuses it; the re-anchor to "a2" + // merges. Batch 2 anchors to its own "a4". + const extractor = fauxEngine([ + submitStep(anchored("a5")), + submitStep(anchored("a2")), + submitStep(anchored("a4")), + ]); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + getChunkTokens: () => 4096, + extractor, + // One review per batch job (the retry lives in the extractor's + // own turn budget, not the auditor's). + auditor: fauxEngine([passThroughAuditStep(), passThroughAuditStep()]), + }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 0 }); + const anchors = ledgers.readNodes().map((n) => n.provenance[0]!.messageId).sort(); + assert.deepEqual(anchors, ["a2", "a4"], "each job's node anchors INSIDE its own batch — never a5"); + assert.equal(ledgers.readCoverage().length, 5, "both batches checkpointed"); + }); + + it("a LINK-APPEND carries its proposition's anchor into the existing node's provenance", async () => { + const { chatId } = fixtureChat(); + const ledgers = memoryLedgers(); + const existingId = "n_anchor_existing"; + ledgers.appendNode({ + id: existingId, + name: "Derivative", + statement: "Derivatives measure rates of change", + aliases: [], + createdAt: "2026-08-01T00:00:00.000Z", + provenance: [], + }); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + extractor: submitModel("submit_findings", anchored("a2")), + auditor: submitModel("submit_review", { links: [{ id: "p1", linkTo: existingId }] }), + }); + await pipeline.runExtraction(chatId); + // The link-append line — the re-encounter — carries the passage id. + const appended = ledgers + .readNodes() + .filter((n) => n.id === existingId) + .find((line) => line.provenance.some((pt) => pt.chatId === chatId))!; + assert.deepEqual(appended.provenance, [{ chatId, messageId: "a2" }]); + // And the derived graph unions it under the same point identity. + const merged = getGraph(ledgers).nodes.find((n) => n.id === existingId)!; + assert.ok(merged.provenance.some((pt) => pt.chatId === chatId && pt.messageId === "a2")); + }); + + it("the notes door stays anchor-free: no messageId required — and a stray one never reaches provenance", async () => { + const ledgers = memoryLedgers(); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + // NOTES_FINDINGS carries no messageId (the notes schema never + // asks for one) — the submit validates exactly as before. + extractor: submitModel("submit_findings", NOTES_FINDINGS), + auditor: submitModel("submit_review", { links: [] }), + }); + assert.deepEqual(await pipeline.importNotes({ filePath: "/vault/anchor-free.md", text: NOTES_TEXT }), { nodes: 1, chains: 0 }); + assert.deepEqual( + ledgers.readNodes()[0]!.provenance, + [{ chatId: ledgers.readSources()[0]!.id, messageId: "" }], + "notes provenance stays chat-level — the file is the anchor", + ); + + // The boundary: even a hallucinated messageId (extra properties pass + // schema validation) is stripped at the notes fill seam. + const stray = { + propositions: [{ ...NOTES_FINDINGS.propositions[0], messageId: "made_up" }], + chains: [], + }; + const second = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + extractor: submitModel("submit_findings", stray), + auditor: submitModel("submit_review", { links: [] }), + }); + assert.deepEqual(await second.importNotes({ filePath: "/vault/stray-anchor.md", text: NOTES_TEXT }), { nodes: 1, chains: 0 }); + const secondSource = ledgers.readSources()[1]!; + const strayNode = ledgers.readNodes().find((n) => n.provenance.some((pt) => pt.chatId === secondSource.id))!; + assert.deepEqual(strayNode.provenance, [{ chatId: secondSource.id, messageId: "" }], "the stray anchor was stripped before the merger"); + }); + + it("coverage fingerprints are UNCHANGED by the anchors: the stored fps equal the plain coverage-slot hashes", async () => { + const { chatId, record } = chunkyChat(); + const ledgers = memoryLedgers(); + const pipeline = createExtractionPipeline({ + chatStore, + graphStore: ledgers, + getChunkTokens: () => 4096, + extractor: fauxEngine([findingsStep(), findingsStep()]), + auditor: fauxEngine([passThroughAuditStep(), passThroughAuditStep()]), + }); + await pipeline.runExtraction(chatId); + // The labeled view reuses messageViewLines; the fingerprint seam is + // the same lines it always was — same hashes, same coverage. + assert.deepEqual( + ledgers.readCoverage().map((c) => c.fp).sort(), + coverageSlots(record.messages).map((slot) => slot.fp).sort(), + ); + assert.equal(coverageOf(record.messages, ledgers.readCoverage(), chatId).uncoveredCount, 0); + }); +}); + // ── the notes front door (format-agnostic import) ──────────────────────────── /** Raw personal notes — arbitrary format, no session, no message ids. */ @@ -1069,9 +1274,9 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable const ledgers = memoryLedgers(); const extractor = submitModel("submit_findings", { propositions: [ - { id: "p1", name: "A", statement: "A holds.", aliases: [] }, - { id: "p2", name: "B", statement: "B holds.", aliases: [] }, - { id: "p3", name: "C", statement: "A and B together entail C.", aliases: [] }, + { id: "p1", name: "A", statement: "A holds.", aliases: [], messageId: "a1" }, + { id: "p2", name: "B", statement: "B holds.", aliases: [], messageId: "a2" }, + { id: "p3", name: "C", statement: "A and B together entail C.", aliases: [], messageId: "a1" }, ], chains: [{ premises: ["p1", "p2"], conclusion: "p3" }], }); @@ -1100,10 +1305,10 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable const ledgers = memoryLedgers(); const extractor = submitModel("submit_findings", { propositions: [ - { id: "p1", name: "A", statement: "A holds.", aliases: [] }, - { id: "p2", name: "B", statement: "B holds.", aliases: [] }, - { id: "p3", name: "C", statement: "C follows.", aliases: [] }, - { id: "p4", name: "Lonely", statement: "A bare proposition no argument uses.", aliases: [] }, + { id: "p1", name: "A", statement: "A holds.", aliases: [], messageId: "a1" }, + { id: "p2", name: "B", statement: "B holds.", aliases: [], messageId: "a2" }, + { id: "p3", name: "C", statement: "C follows.", aliases: [], messageId: "a1" }, + { id: "p4", name: "Lonely", statement: "A bare proposition no argument uses.", aliases: [], messageId: "a2" }, ], chains: [ { premises: ["p1"], conclusion: "p3" }, @@ -1238,6 +1443,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable assert.ok(NOTES_EXTRACTOR_INSTRUCTIONS.startsWith(NOTES_PREAMBLE)); assert.ok(NOTES_EXTRACTOR_INSTRUCTIONS.includes(EXTRACTION_CORE), "the notes extractor shares the core by construction"); + // THE ANCHOR RULE (owner, 2026-10-13) is SESSION-ONLY: the exact + // sentence rides the session instructions; the notes + // contextualization never mentions message ids at all. + assert.equal( + SESSION_MESSAGE_ID_RULE, + "For each new proposition, copy the message ID of the passage that most directly states or explains it into messageId.", + ); + assert.ok(EXTRACTOR_INSTRUCTIONS.includes(SESSION_MESSAGE_ID_RULE), "the session instructions carry the rule"); + assert.ok(!NOTES_EXTRACTOR_INSTRUCTIONS.includes("messageId"), "the notes prompt never mentions message ids"); + // The preambles differ in exactly their source description. assert.notEqual(SESSION_PREAMBLE, NOTES_PREAMBLE); assert.match(SESSION_PREAMBLE, /a teaching\s+session, rendered as the teacher's prose in reading order/); @@ -1431,12 +1646,22 @@ function capturedView(context: unknown): string { return typeof last?.content === "string" ? last.content : ""; } -/** An extractor step that submits FINDINGS, optionally capturing its view. */ +/** An extractor step that submits FINDINGS, optionally capturing its view. + * The step plays by the anchor law it runs under (2026-10-13): every + * proposition's messageId is copied from a TEACHER [id] label of the + * CURRENT stretch, so multi-batch fixtures reuse this step across batches + * without ever naming a passage their batch does not hold. */ function findingsStep(capture?: (view: string) => void): FauxResponseStep { return (context) => { - capture?.(capturedView(context)); + const view = capturedView(context); + capture?.(view); + const anchor = /TEACHER \[([^\]]+)\]/.exec(view)?.[1] ?? "a1"; + const submit = { + ...FINDINGS, + propositions: FINDINGS.propositions.map((p) => ({ ...p, messageId: anchor })), + }; return fauxAssistantMessage( - [fauxToolCall("submit_findings", FINDINGS as unknown as ToolCall["arguments"], { id: "findings-call" })], + [fauxToolCall("submit_findings", submit as unknown as ToolCall["arguments"], { id: "findings-call" })], { stopReason: "toolUse" }, ); }; @@ -1487,7 +1712,8 @@ 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, 2026-09-30). + // default again — halved back from the digest-era 8192, owner + // 2026-10-13). getChunkTokens: () => 4096, extractor: fauxEngine([findingsStep((v) => views.push(v)), findingsStep((v) => views.push(v))]), auditor: fauxEngine([passThroughAuditStep(), passThroughAuditStep()]), @@ -1501,9 +1727,17 @@ describe("the chunked pipeline (extraction coverage)", () => { assert.ok(!views[0]!.includes("topic 4:"), "the batch stops at the token budget"); assert.ok(views[1]!.includes("topic 4:") && views[1]!.includes("topic 5:")); assert.ok(!views[1]!.includes("topic 3:")); - assert.doesNotMatch(views[0]!, /### /, "plain paragraphs — no speaker headers"); + assert.doesNotMatch(views[0]!, /### /, "no speaker headers — passages are TEACHER [id] lines"); // The document header is the session title. assert.ok(views[0]!.startsWith("# Chunky coverage fixture")); + // MESSAGE-ID LABELS (2026-10-13): every passage carries its REAL + // message id — batch 1 holds a1-a3, batch 2 a4-a5, and the labels + // follow the split exactly. + assert.match(views[0]!, /^TEACHER \[a1\] topic 1:/m); + assert.match(views[0]!, /^TEACHER \[a3\] topic 3:/m); + assert.doesNotMatch(views[0]!, /TEACHER \[a4\]/); + assert.match(views[1]!, /^TEACHER \[a4\] topic 4:/m); + assert.match(views[1]!, /^TEACHER \[a5\] topic 5:/m); // Batch 1 merged the full findings (verdicts-only: the extractor's // output always fully merges); batch 2's identical ids are a @@ -1538,7 +1772,8 @@ 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, 2026-09-30). + // default again — halved back from the digest-era 8192, owner + // 2026-10-13). getChunkTokens: () => 4096, // Batch 1 submits and merges; batch 2's extractor call HANGS // until the abort fires — the dock's abort button, mid-run. @@ -1859,7 +2094,7 @@ describe("digest-aware serialized extraction (the extractor is the assembler)", }); ledgers.appendNode(seed("n_a", "Self-ownership")); const FINDINGS_B: SessionFindings = { - propositions: [{ id: "p1", name: "Appropriation axiom", statement: "c", aliases: [] }], + propositions: [{ id: "p1", name: "Appropriation axiom", statement: "c", aliases: [], messageId: "a1" }], chains: [{ premises: ["n_a", "p1"], conclusion: "p1", }].slice(0, 0), // chains live in the engine below }; const views: string[] = []; @@ -1871,7 +2106,7 @@ describe("digest-aware serialized extraction (the extractor is the assembler)", chains: [{ premises: ["n_a", "p1"], conclusion: "p1b" }], }; // conclusion p1b must exist — real submit: - submit.propositions.push({ id: "p1b", name: "Property rights", statement: "d", aliases: [] }); + submit.propositions.push({ id: "p1b", name: "Property rights", statement: "d", aliases: [], messageId: "a2" }); return fauxAssistantMessage([fauxToolCall("submit_findings", submit as unknown as ToolCall["arguments"], { id: "f1" })], { stopReason: "toolUse" }); }, ]); @@ -1918,7 +2153,7 @@ describe("digest-aware serialized extraction (the extractor is the assembler)", fauxToolCall( "submit_findings", { - propositions: [{ id: "p1", name: "Axiom", statement: "s", aliases: [] }], + propositions: [{ id: "p1", name: "Axiom", statement: "s", aliases: [], messageId: "a1" }], chains: [], } as unknown as ToolCall["arguments"], { id: "good" }, @@ -1944,11 +2179,18 @@ describe("digest-aware serialized extraction (the extractor is the assembler)", let extractCalls = 0; let auditCalls = 0; // One step per engine call (faux engines consume steps): two batches. - const extractStep = (): FauxResponseStep => () => { + const extractStep = (): FauxResponseStep => (context) => { extractCalls++; events.push(`extract${extractCalls}`); + // The anchor is copied from the CURRENT stretch's first TEACHER + // label (a1 in batch 1, a4 in batch 2) — batch-local by construction. + const anchor = /TEACHER \[([^\]]+)\]/.exec(capturedView(context))?.[1] ?? "a1"; + const submit = { + ...FINDINGS, + propositions: FINDINGS.propositions.map((p) => ({ ...p, messageId: anchor })), + }; return fauxAssistantMessage( - [fauxToolCall("submit_findings", FINDINGS as unknown as ToolCall["arguments"], { id: `f${extractCalls}` })], + [fauxToolCall("submit_findings", submit as unknown as ToolCall["arguments"], { id: `f${extractCalls}` })], { stopReason: "toolUse" }, ); }; @@ -1967,7 +2209,7 @@ describe("digest-aware serialized extraction (the extractor is the assembler)", graphStore: ledgers, extractor, auditor, - getChunkTokens: () => 4096, // two batches over the chunky fixture (factory default doubled to 8192, 2026-09-30) + getChunkTokens: () => 4096, // two batches over the chunky fixture (the factory default, 4096 — halved back 2026-10-13) }); const stats = await pipeline.runExtraction(chatId); assert.deepEqual(events, ["extract1", "audit1", "extract2", "audit2"], "strict per-source order: extract → audit → merge → NEXT extract"); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 877a154..e21203c 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -290,9 +290,10 @@ describe("the merger (mergeFindings)", () => { assert.equal("kind" in node, false, "no kind field"); assert.ok(Array.isArray(node.provenance), "provenance attached"); } - // Provenance is CHAT-LEVEL (submit-schema slim, 2026-09-28): - // propositions no longer carry message ids — the run's source key - // is the whole provenance. + // Provenance without an anchor stays CHAT-LEVEL: this caller submits + // no messageId (the legacy/core + notes shape — anchors are the + // session door's alone, 2026-10-13), so the run's source key is the + // whole provenance point. const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!; assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]); @@ -306,6 +307,60 @@ describe("the merger (mergeFindings)", () => { }); + it("messageId anchors (2026-10-13): creates AND link-appends stamp the passage id; chains stay chat-level", () => { + // The session submit's anchor (owner, 2026-10-13): a proposition may + // carry its passage's message id — the merger stamps it into the + // node's provenance point for BOTH births and re-encounters. A + // caller without one (notes, legacy/core — pinned above) keeps "". + const chat = fixtureChat( + "Anchor fixture", + [ + ...user("u1", "teach anchors"), + ...teacher("t1", "First anchored passage."), + ...teacher("t2", "Second anchored passage."), + ].flat(), + ); + const first = mergeFindings( + chat.id, + { + propositions: [{ id: "p1", name: "Anchored", statement: "Anchored statement", aliases: [], messageId: "t1" }], + chains: [], + }, + { links: [] }, + graphDeps, + sessionSource(chat.id), + ); + assert.equal(first.nodes, 1); + const created = scopedGraph([chat.id]).nodes.find((n) => n.statement === "Anchored statement")!; + assert.deepEqual(created.provenance, [{ chatId: chat.id, messageId: "t1" }], "the created node anchors to its passage"); + + // The re-encounter: link-appended, anchored to ITS passage — while + // the chain the same job asserts stays chat-level. + const second = mergeFindings( + chat.id, + { + propositions: [ + { id: "q1", name: "Anchored again", statement: "Anchored statement, rephrased", aliases: [], messageId: "t2" }, + { id: "q2", name: "Follows", statement: "Follows the anchored claim", aliases: [], messageId: "t2" }, + ], + chains: [{ premises: ["q1"], conclusion: "q2" }], + }, + { links: [{ id: "q1", linkTo: created.id }] }, + graphDeps, + sessionSource(chat.id), + ); + assert.equal(second.nodes, 1, "q1 linked (no create), q2 created"); + const linkLine = graphStore + .readNodes() + .filter((n) => n.id === created.id) + .find((line) => line.provenance.some((pt) => pt.chatId === chat.id && pt.messageId === "t2")); + assert.ok(linkLine, "the link-append line carries the re-encounter's passage id"); + assert.equal(linkLine!.name, "Anchored again", "the appended line is the re-encounter (reads merge it into aliases)"); + const chains = graphStore.readChains().filter((c) => c.provenance.some((pt) => pt.chatId === chat.id)); + assert.equal(chains.length, 1); + assert.deepEqual(chains[0]!.provenance, [{ chatId: chat.id, messageId: "" }], "chains stay chat-level — never anchored"); + }); + it("merges INDEPENDENTLY per call — no cross-run identity (owner, 2026-09-29)", () => { // The mappings ledger is gone: resolution is a job-local in-memory // map, so a second merge of the same verdicts knows nothing of the diff --git a/tests/render-extraction-coverage.test.tsx b/tests/render-extraction-coverage.test.tsx index ed933dd..fbbfab1 100644 --- a/tests/render-extraction-coverage.test.tsx +++ b/tests/render-extraction-coverage.test.tsx @@ -40,8 +40,10 @@ describe("fetchGraphRead — one truth with the extraction trigger (2026-10-02)" // effectiveSettings gate waits for. The old hand-rolled fallback // read the raw delta and guessed `?? 4096` — a stale pre-setting-era // constant — so an unset budget filled the ring at half the truth - // (owner repro: 6k shown full, extraction correctly silent at 8192). - // Now the raw delta resolves through the SAME core resolver. + // (owner repro: 6k shown full, extraction correctly silent at the then- + // factory 8192). The 2026-10-13 ruling restored the factory to 4096 (more + // frequent graph updates); the LAW is unchanged — the denominator is the + // factory the same core resolver serves, never a hand-rolled constant. const stub = (payload: unknown, ok = true) => { const real = globalThis.fetch; globalThis.fetch = (async () => @@ -51,25 +53,25 @@ describe("fetchGraphRead — one truth with the extraction trigger (2026-10-02)" }; }; - it("an absent graph section denominates at the factory 8192 — never 4096, never a missing ring", async () => { + it("an absent graph section denominates at the factory 4096 — never a hand-rolled constant, never a missing ring", async () => { const restore = stub({ settings: { version: 1 } }); const read = await fetchGraphRead(); restore(); - assert.deepEqual(read, { enabled: true, chunkTokens: 8192 }); + assert.deepEqual(read, { enabled: true, chunkTokens: 4096 }); }); - it("the owner repro shape — graph section present, budget never written — is 8192", async () => { + it("the owner repro shape — graph section present, budget never written — is the factory", async () => { const restore = stub({ settings: { version: 1, graph: { enabled: true } } }); const read = await fetchGraphRead(); restore(); - assert.equal(read?.chunkTokens, 8192, "the record lacks the key: factory, not the stale 4096"); + assert.equal(read?.chunkTokens, 4096, "the record lacks the key: factory, not a stale constant"); }); it("first boot (settings null) resolves factory — the ring shows, at the true budget", async () => { const restore = stub({ settings: null }); const read = await fetchGraphRead(); restore(); - assert.deepEqual(read, { enabled: true, chunkTokens: 8192 }); + assert.deepEqual(read, { enabled: true, chunkTokens: 4096 }); }); it("a custom budget rides through verbatim", async () => { diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index b3ed9b3..dcb235a 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -203,9 +203,9 @@ describe("GraphSection — master switch + chunk budget (wave 7 + 2026-10-02)", it("the chunk-budget row: unset shows EMPTY with the factory placeholder + hint — one truth with the trigger", () => { const html = renderToStaticMarkup( {}} />); const input = tagOf(html, "input-chunk-tokens"); - assert.ok(input.includes('placeholder="8192"'), "the placeholder IS the resolved factory default"); - assert.ok(!/value="\d/.test(input), "unset renders EMPTY, not a materialized 8192"); - assert.ok(html.includes(">8192 tok4096 tok { diff --git a/tests/settings-resolution.test.ts b/tests/settings-resolution.test.ts index 3bca067..9297e54 100644 --- a/tests/settings-resolution.test.ts +++ b/tests/settings-resolution.test.ts @@ -43,8 +43,9 @@ describe("resolveSettings", () => { assert.equal(eff.appearance.sessionNameSerif, true); assert.equal(eff.appearance.mdRendering.headingWeight, 900); // wave 7 collapse: the master switch ONLY + the redesign's chunk - // budget — absent record resolves the factory 8192 (the 2026-09-30 bump) - assert.deepEqual(eff.graph, { enabled: true, rendering: true, extractionChunkTokens: 8192 }); + // budget — absent record resolves the factory 4096 (halved back + // from the digest-era 8192, owner, 2026-10-13) + assert.deepEqual(eff.graph, { enabled: true, rendering: true, extractionChunkTokens: 4096 }); }); it("explicit record values win over every factory default", () => { @@ -76,8 +77,8 @@ describe("resolveSettings", () => { assert.equal(eff.appearance.proseFontSize, 20); assert.equal(eff.appearance.mdRendering.headingWeight, 600); // wave 7 collapse: the section IS the master switch (absent budget - // field = factory 8192 — a pre-redesign record resolves cleanly). - assert.deepEqual(eff.graph, { enabled: false, rendering: true, extractionChunkTokens: 8192 }); + // field = factory 4096 — a pre-redesign record resolves cleanly). + assert.deepEqual(eff.graph, { enabled: false, rendering: true, extractionChunkTokens: 4096 }); // The DISPLAY split (owner, 2026-10-06): extraction off leaves the // rendering switch ON — the data channel and the view are separate. assert.equal(renderingEnabled(eff.graph), true); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 5ba376e..35111de 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -22,9 +22,10 @@ describe("FACTORY_SETTINGS (the compiled factory layer)", () => { assert.equal(CURRENT_SETTINGS_VERSION, 1); 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 digest-aware redesign (owner, 2026-09-30) - assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, rendering: true, extractionChunkTokens: 8192 }); + // redesign's chunk budget (2026-09-24); doubled to 8192 with the + // digest-aware redesign (2026-09-30), then HALVED BACK to 4096 + // with the message anchors (owner, 2026-10-13) + assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, rendering: true, extractionChunkTokens: 4096 }); const a = FACTORY_SETTINGS.appearance; assert.equal(a.mode, "dark"); assert.equal(a.background, null); // the mode's own default, not a factory hex