From bd9694b0e4fd842a5c24abd264bfb30ffcd58390 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 28 Sep 2026 19:04:39 +0300 Subject: [PATCH] slim the submit schemas: evidence and messages die; stringified-payload heal at the pre-validation seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter log diagnosis (owner, 2026-09-28): extraction time was dominated by tool-validation retries. The failing call had the whole propositions array emitted as ONE JSON-STRING ('propositions.0: must be object'), a truncated placeholder artifact inside it, and word-salad statements late in the array — payload-size collapse. Field audit: 'evidence' was never persisted anywhere and its faithfulness-checking consumer died with the fully-trusted ruling; 'messages' fed messageId granularity that NOTHING downstream reads, and the model sent [] anyway (chat-level provenance in practice). 'aliases' stays (optional now): the auditor's find tool globs them. Schema: propositions = {id, name, statement, aliases?}; edges = {from, to, type}. Prompts lose the evidence/messages doctrine and [id]-anchor guidance, gain a compact faithfulness line; transcriptView renders anchorless. The auditor echo instruction drops the evidence/messages mention, so its payload halves too. Heal: healStringifiedArrays on the submit tool's prepareArguments (pi-agent-core's sanctioned pre-TypeBox shim) parses a stringified propositions/edges back into arrays — the LaTeX-restore doctrine: heal weak-model wire behavior at the seam. Test drives the real agent-loop seam with a stringified submit. --- docs/architecture.md | 2 +- src/agent/subagents/extraction.ts | 80 ++++++++++++++++++------------- src/core/contracts/graph.ts | 18 ++++--- src/core/graph.ts | 22 ++++----- src/core/session-markdown.ts | 8 ++-- tests/extraction.test.ts | 63 ++++++++++++++---------- tests/graph.test.ts | 63 +++++++++--------------- 7 files changed, 131 insertions(+), 125 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5b631a1..ea08b9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -338,7 +338,7 @@ flowchart TB MERGE -->|"failure at ANY stage"| NOTHING["checkpoints nothing — the next trigger re-runs"] ``` -**Raw reasoning chains only** (2026-09-27 cleanup): the extractor reads ONLY the session — the whole thing, rendered as readable markdown by the shared engine (`core/session-markdown.ts`; the same renderer will serve future user-facing session export) — and proposes free-form propositions (true statements; a misconception is stated as its true correction) and `follows`/`supports` edges with verbatim evidence. Every node is an objective, world-facing fact. No quiz outcomes, no understanding assessments — the events ledger and `UnderstandingLevel` are deleted; the learner's relationship to a proposition becomes practice-module territory (assessment records, later). +**Raw reasoning chains only** (2026-09-27 cleanup): the extractor reads ONLY the session — the whole thing, rendered as readable markdown by the shared engine (`core/session-markdown.ts`; the same renderer will serve future user-facing session export) — and proposes free-form propositions (true statements; a misconception is stated as its true correction) and `follows`/`supports` edges. The submit schemas are slim (owner, 2026-09-28): propositions carry id/name/statement/optional aliases and edges carry from/to/type — the `messages` and `evidence` fields are gone (provenance is chat-level; quote fields were never persisted and weak models burned retries on them), and a `prepareArguments` heal at the pre-validation seam rescues stringified-payload submits. Every node is an objective, world-facing fact. No quiz outcomes, no understanding assessments — the events ledger and `UnderstandingLevel` are deleted; the learner's relationship to a proposition becomes practice-module territory (assessment records, later). **Serialized audit** (owner ruling 2026-09-28): extraction may run in parallel across sources, but the audit→merge section is a process-wide critical section — a FIFO mutex on `globalThis` (the same module-instance idiom as the run registries). Before any auditor LLM call or graph read, a run checks the gate and waits (`waiting` phase, visible in the status feed) until every earlier run's merge has landed, so each auditor explores the graph as it actually stands — two concurrent audits would each link into a graph the other is about to change, the structural mint of duplicate nodes. The lock is reacquired per batch (interleaved runs each see the latest graph instead of starving) and is abort-aware on both sides of the queue. diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 5df0a01..cc78d97 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -38,7 +38,7 @@ import { auditorGraphTools } from "../tools/graph.ts"; * rendered as markdown by the shared session-markdown * engine, or a notes file verbatim) → SessionFindings: * propositions + follows/supports edges, each with - * verbatim evidence. TREE-BLIND, graph-blind, FULLY + * TREE-BLIND, graph-blind, FULLY * TRUSTED — nothing downstream re-checks it. * auditor (LLM) never sees the source. Its job is IDENTITY: for each * proposition, linkTo an existing graph node or create — @@ -96,22 +96,15 @@ import { auditorGraphTools } from "../tools/graph.ts"; * re-opens covered history (the deliberate 2026-09-27 split). */ export function transcriptView(record: ChatRecord): string { - return sessionMarkdown(record.messages, { title: record.title }); + // anchors OFF (submit-schema slim, 2026-09-28): propositions no longer + // carry message ids, so the [id] anchors are noise for the extractor. + return sessionMarkdown(record.messages, { title: record.title, anchors: false }); } // ─── the extractor ────────────────────────────────────────────────────────── -const evidenceSchema = Type.Array(Type.String(), { - minItems: 1, - description: "Verbatim quotes from the transcript that back this. The auditor will check every quote.", -}); - -const messagesSchema = Type.Array(Type.String(), { - description: "The message ids (from the [id] tags in the transcript) where this came up — provenance.", -}); - const propositionProps = { - id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by edges and attribution.' }), + id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by edges.' }), name: Type.String({ description: "A very short label for the proposition — a few words, a compact handle like a glossary headword " + @@ -125,11 +118,11 @@ const propositionProps = { '"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' + 'might-makes-right social Darwinism") — wrongness is never recorded in the graph.', }), - aliases: Type.Array(Type.String(), { - description: "Verbatim phrasings this session used for the same proposition, if any.", - }), - messages: messagesSchema, - evidence: evidenceSchema, + aliases: Type.Optional( + Type.Array(Type.String(), { + description: "Verbatim phrasings this session used for the same proposition, if any.", + }), + ), }; const edgeSchema = Type.Object({ @@ -138,8 +131,6 @@ const edgeSchema = Type.Object({ type: Type.Union([Type.Literal("follows"), Type.Literal("supports")], { description: '"follows" = to is derivable from from; "supports" = from grounds to without entailing it.', }), - messages: messagesSchema, - evidence: evidenceSchema, }); // Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` / @@ -187,18 +178,44 @@ const auditSchema = Type.Object(auditProps); type NotesFindings = SessionFindings; type NotesAuditResult = AuditResult; +// ── the weak-model string-heal (owner, 2026-09-28) ── +// +// OpenRouter log evidence: weak models under a big submit payload often +// emit the whole `propositions` array as ONE JSON-STRING instead of a +// native array ("propositions.0: must be object"). pi-agent-core runs +// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim +// point (same doctrine as restoreMathEscapes at the display seam: heal +// weak-model wire behavior, don't punish it with a retry). Best-effort: +// heal only a string that parses to an array; anything else passes +// through untouched and fails validation exactly as before. +function healStringifiedArrays(raw: unknown): unknown { + if (typeof raw !== "object" || raw === null) return raw; + const out: Record = { ...(raw as Record) }; + for (const key of ["propositions", "edges"]) { + const v = out[key]; + if (typeof v !== "string" || v.trim().length === 0) continue; + try { + const parsed: unknown = JSON.parse(v); + if (Array.isArray(parsed)) out[key] = parsed; + } catch { + // not a clean stringify (e.g. truncated) — leave for validation + } + } + return out; +} + // ── the weak-model ??-fills (the old zod defaults, applied at submit) ── function fillFindings(raw: Static): SessionFindings { return { - propositions: raw.propositions, + propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), edges: raw.edges ?? [], }; } function fillAudit(raw: Static): AuditResult { return { - propositions: raw.propositions, + propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), edges: raw.edges ?? [], }; } @@ -229,7 +246,6 @@ node of its own wrong claim — state the TRUE correction as the proposition. edge (see 2). - Notes may explain things at length or quote material the author is studying: extract the propositions themselves, one reasoning step each — never a topic blob, never a sub-step. - - "messages": ALWAYS an empty list — provenance is the file itself. - "aliases": verbatim phrasings the notes used for the same proposition. 2. EDGES between propositions — the connective tissue; a graph of lonely nodes is a failed extraction: @@ -239,10 +255,9 @@ node of its own wrong claim — state the TRUE correction as the proposition. - COMPLETENESS: whenever these notes present one proposition as evidence for, consequence of, or prerequisite of another, that edge MUST be in your findings. Omitting a real relation is the main way extraction fails — do not leave obviously related propositions unconnected. - - Every edge cites evidence: a quote appearing VERBATIM in the source. -Evidence discipline: every quote must appear VERBATIM in the source — this is your self-grounding, and the proof the -statements come from the material rather than invention. +Faithfulness: extract only what the source actually articulates — never invent, pad, or compress into vagueness. When +in doubt about a proposition's exact content, quote its source passage to yourself first, then state it precisely. Include EVERY top-level field of the call even when empty — "edges": [] when there are no relations. NEVER omit a field because it is empty. @@ -252,9 +267,9 @@ Your entire deliverable is ONE submit_findings call.`; const EXTRACTOR_INSTRUCTIONS = `You are the extraction specialist in a learning application. You read ONE session rendering and report what that session teaches — as structured findings for the learner's global knowledge graph. -The session is rendered as readable markdown: Teacher and Learner passages in order (each header carries its message -id — the [id] anchor), Quiz / Recall-check / Question blocks for the embedded checks, and one-line notes where other -tools ran. Read ALL of it as the session's teaching content. +The session is rendered as readable markdown: Teacher and Learner passages in order, Quiz / Recall-check / +Question blocks for the embedded checks, and one-line notes where other tools ran. Read ALL of it as the session's +teaching content. You are the ONLY stage that sees this source, and you are FULLY TRUSTED: nothing downstream re-checks your work. Extract carefully — the graph will hold exactly what you report. @@ -279,7 +294,6 @@ a node of its own wrong claim — state the TRUE correction as the proposition. edge (see 2). - Include corrections of misconceptions the teacher articulated, and true facts explained in prose. - One reasoning step per proposition: never a topic blob, never a sub-step. - - "messages": the id from the header anchor of every passage where the proposition came up — provenance, report it. - "aliases": verbatim phrasings this session used for the same proposition. 2. EDGES between propositions — the connective tissue; a graph of lonely nodes is a failed extraction: @@ -289,10 +303,9 @@ a node of its own wrong claim — state the TRUE correction as the proposition. - COMPLETENESS: whenever this session presents one proposition as evidence for, consequence of, or prerequisite of another, that edge MUST be in your findings. Omitting a real relation is the main way extraction fails — do not leave obviously related propositions unconnected. - - Every edge cites evidence: a quote appearing VERBATIM in the source markdown. -Evidence discipline: every quote must appear VERBATIM in the source markdown (a Quiz block's question counts as the -source). This is your self-grounding: the proof that statements come from the material rather than invention. +Faithfulness: extract only what the session actually articulates — never invent, pad, or compress into vagueness. When +in doubt about a proposition's exact content, quote its source passage to yourself first, then state it precisely. Include EVERY top-level field of the call even when empty — "edges": [] when there are no relations. NEVER omit a field because it is empty. @@ -335,6 +348,7 @@ async function runSubmitAgent(opts: { label: opts.toolName, description: opts.toolDescription, parameters: opts.parameters, + prepareArguments: (args) => healStringifiedArrays(args) as never, execute: async (_toolCallId, input) => { submitted = opts.fill(input); return toToolResult(submitted); @@ -442,7 +456,7 @@ BRIDGING EDGES — the one place you create edges: - NEVER drop or rewrite the extractor's own propositions or edges — pass them through unchanged (adding linkTo is the only edit). Never invent propositions. -Reproduce every proposition and edge you received, with their evidence and messages verbatim. Your entire deliverable +Reproduce every proposition and edge you received verbatim. Your entire deliverable is ONE submit_review call. Include EVERY top-level field of the call even when empty — "edges": []. NEVER omit a field because it is empty.`; diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index a5e8703..2ccbfc7 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -172,7 +172,7 @@ export interface CoverageEntry { * of work): the extractor is the ONLY stage that sees the raw source * (whole session as readable markdown, or raw notes) and extracts the * reasoning chains — propositions (lonely relevant ones included) plus - * follows/supports edges, with verbatim evidence; the auditor NEVER sees + * follows/supports edges; the auditor NEVER sees * the source — its job is preparing the merge: identity verdicts * (link-or-create, merging equivalent forms of the same proposition) via * graph-exploration tools (find / list_sessions / list_propositions), and @@ -187,7 +187,7 @@ export interface CoverageEntry { /** One proposed proposition — a node candidate (or a re-encounter of one). */ export interface ExtractedProposition { - /** Extractor-local id ("p1", …) referenced by edges + attribution. */ + /** Extractor-local id ("p1", …) referenced by edges. */ id: string; /** A very short label — a few words, a compact handle like a glossary * headword. Not a sentence, needn't be unique or self-contained: the @@ -202,10 +202,12 @@ export interface ExtractedProposition { statement: string; /** Verbatim phrasings the session used. */ aliases: string[]; - /** The message ids where this proposition came up — provenance. */ - messages: string[]; - /** Verbatim transcript quotes backing the proposition. */ - evidence: 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. */ } /** One proposed relation. Endpoints are extractor-local proposition ids @@ -219,10 +221,6 @@ export interface ExtractedEdge { /** Proposition id (or graph node id) that follows / is supported. */ to: string; type: EdgeType; - /** The message ids where this relation was asserted — provenance. */ - messages: string[]; - /** Verbatim transcript quotes backing the relation. */ - evidence: string[]; } /** The extractor's output shape: the raw reasoning chains. */ diff --git a/src/core/graph.ts b/src/core/graph.ts index d749aa2..54dcfc8 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -85,16 +85,11 @@ export interface MergeSource { coverage?: CoverageEntry[]; } -/** (chatId, messageIds) → provenance points; order-preserving, deduped. */ -function pointsOf(chatId: string, messages: string[]): ProvenancePoint[] { - const seen = new Set(); - const out: ProvenancePoint[] = []; - for (const messageId of messages) { - if (messageId.length === 0 || seen.has(messageId)) continue; - seen.add(messageId); - out.push({ chatId, messageId }); - } - return out; +/** 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: "" }]; } const samePoint = (a: ProvenancePoint, b: ProvenancePoint): boolean => @@ -140,7 +135,7 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep for (const p of audit.propositions) { if (mapping.has(p.id)) continue; // already resolved for this chat — idempotent - const points = pointsOf(chatId, p.messages); + const points = pointOf(chatId); let graphNodeId: string; if (p.linkTo !== undefined && existingIds.has(p.linkTo)) { // Identity verdict: this proposition IS that node. Append a link @@ -206,7 +201,10 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep from, to, type: edge.type, - provenance: bridged ? [{ chatId, messageId: "" }] : pointsOf(chatId, edge.messages), + // bridged vs transcript edges both carry the run's chat-level + // point now; the distinction lives in the endpoint resolution + // above (mapping vs existing node id), not in the provenance. + provenance: pointOf(chatId), }); assertedByChat.add(key); stats.edges++; diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts index 0a911b0..4321b72 100644 --- a/src/core/session-markdown.ts +++ b/src/core/session-markdown.ts @@ -4,9 +4,11 @@ * The extractor's view of a teaching session and the future user-facing * session export share this engine: a session is rendered as clean, * readable markdown — speaker headers, quiz/check-in blocks, tool notes — - * with the message-id anchors preserved (extraction provenance depends on - * the extractor reporting the `[id]` tags it saw; `anchors: false` mints - * the same document without them, for the user export). + * with optional message-id anchors. Since the submit-schema slim + * (2026-09-28) propositions no longer report message ids — the extractor + * renders anchorless and provenance is chat-level — so the anchors today + * serve only the future user-facing export; `anchors: false` mints the + * same document without them. * * Pure core: native messages in (LhMessage), string out. Content parity * with the coverage line builder (core/coverage.ts messageViewLines) is diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 8321c5d..9585b72 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -221,7 +221,7 @@ after(() => { }); /** What the extractor would produce for the fixture session — tree-blind, - * free-form, with message-granular provenance. */ + * free-form, with chat-level provenance. */ const FINDINGS: SessionFindings = { propositions: [ { @@ -229,16 +229,12 @@ const FINDINGS: SessionFindings = { name: "Derivative", statement: "The derivative measures how fast a function changes", aliases: [], - messages: ["a2"], - evidence: ["The derivative measures how fast a function changes."], }, { id: "p2", name: "Chain rule", statement: "The chain rule differentiates composed functions by multiplying local rates.", aliases: [], - messages: ["a2"], - evidence: ["teach me the chain rule"], }, ], edges: [ @@ -246,8 +242,6 @@ const FINDINGS: SessionFindings = { from: "p1", to: "p2", type: "follows", - messages: ["a2"], - evidence: ["teach me the chain rule"], }, ], }; @@ -322,20 +316,21 @@ function silentModel(): PiEngine { // ─── transcriptView (pure) ─────────────────────────────────────────────────── describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27)", () => { - it("renders id-anchored speaker blocks + check blocks — tree-blind, plan ops folded", () => { + it("renders anchorless speaker blocks + check blocks — tree-blind, plan ops folded", () => { const { record } = fixtureChat(); const view = transcriptView(record); // The document header is the session title. assert.match(view, /^# Extraction fixture$/m); - // Conversation verbatim, each block headed by its speaker + the - // message-id anchor — the provenance the extractor reports back. - assert.match(view, /### Learner · u1\n\nteach me the chain rule/); - assert.match(view, /### Teacher · a2\n\nThe derivative measures how fast a function changes\./); + // Conversation verbatim, each block headed by its speaker only — + // the [id] anchors are GONE (submit-schema slim, 2026-09-28: + // propositions no longer report message ids). + assert.match(view, /### Learner\n\nteach me the chain rule/); + assert.match(view, /### Teacher\n\nThe derivative measures how fast a function changes\./); // Checks render as readable blocks (question + outcome). - assert.match(view, /### Quiz · r_probe-1/); + assert.match(view, /### Quiz\n/); assert.match(view, /\*\*Q:\*\* What is a derivative, roughly\?/); assert.match(view, /The learner answered incorrectly\./); assert.match(view, /The learner answered correctly\./); @@ -373,6 +368,25 @@ describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27) // ─── the pipeline ─────────────────────────────────────────────────────────── describe("the extraction pipeline (extractor → auditor → merger)", () => { + it("stringified-payload heal (owner, 2026-09-28): propositions arriving as ONE JSON string still validate and merge", async () => { + const { chatId } = fixtureChat(); + const ledgers = memoryLedgers(); + const verdict: AuditResult = { propositions: FINDINGS.propositions, edges: FINDINGS.edges }; + + // The weak-model failure from the OpenRouter log: the whole array + // emitted as a JSON-STRING ("propositions.0: must be object"). + // pi-agent-core's prepareArguments shim runs BEFORE TypeBox — the + // heal parses it, validation passes, no retry burned. + const extractor = submitModel("submit_findings", { + propositions: JSON.stringify(FINDINGS.propositions), + edges: FINDINGS.edges, + }); + + const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) }); + const stats = await pipeline.runExtraction(chatId); + assert.deepEqual(stats, { nodes: 2, edges: 1 }); + }); + it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => { const { chatId } = fixtureChat(); const ledgers = memoryLedgers(); @@ -631,7 +645,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const ledgers = memoryLedgers(); const single = (id: string, name: string, statement: string): SessionFindings => ({ - propositions: [{ id, name, statement, aliases: [], messages: ["a2"], evidence: ["e"] }], + propositions: [{ id, name, statement, aliases: [], }], edges: [], }); const findingsA = single("pa", "Alpha claim", "Alpha statement, full precision"); @@ -688,7 +702,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const ledgers = memoryLedgers(); const single = (id: string, name: string, statement: string): SessionFindings => ({ - propositions: [{ id, name, statement, aliases: [], messages: ["a2"], evidence: ["e"] }], + propositions: [{ id, name, statement, aliases: [], }], edges: [], }); @@ -938,8 +952,6 @@ const NOTES_FINDINGS = { name: "Vim normal mode", statement: "Vim starts in Normal mode, where keys are commands.", aliases: [], - messages: [], - evidence: ["Vim always starts in Normal mode — keys are commands, not text."], }, ], edges: [], @@ -980,11 +992,12 @@ describe("the notes front door (importNotes — format-agnostic)", () => { assert.equal(source.extractions![0]!.stats?.nodes, 1); assert.ok(source.extractions![0]!.stages.extractor.steps.length > 0, "faux usage captured"); - // File-scoped provenance: the mapping line carries the source id; - // the node carries NO message anchors. + // File-scoped provenance (submit-schema slim, 2026-09-28): the + // node carries ONE chat-level point — the source key itself, no + // message anchors. const node = ledgers.readNodes()[0]!; assert.equal(node.statement, "Vim starts in Normal mode, where keys are commands."); - assert.deepEqual(node.provenance, []); + assert.deepEqual(node.provenance, [{ chatId: source.id, messageId: "" }]); assert.deepEqual(ledgers.readMappings(), [{ chatId: source.id, sourceId: "p1", graphNodeId: node.id }]); // The checkpoint stamps the import. assert.equal(ledgers.readCheckpoints()[0]!.chatId, source.id); @@ -1102,10 +1115,10 @@ describe("the chunked pipeline (extraction coverage)", () => { // Two batches: the first held messages 1-3, the second 4-5 — a // message is never split, batches are consecutive oldest-first. assert.equal(views.length, 2, "one extractor call per batch"); - assert.ok(views[0]!.includes("### Learner · u1") && views[0]!.includes("### Learner · u3")); - assert.ok(!views[0]!.includes("### Learner · u4"), "the batch stops at the token budget"); - assert.ok(views[1]!.includes("### Learner · u4") && views[1]!.includes("### Learner · u5")); - assert.ok(!views[1]!.includes("### Learner · u3")); + assert.ok(views[0]!.includes("### Learner") && views[0]!.includes("topic 1:")); + assert.ok(!views[0]!.includes("topic 4:"), "the batch stops at the token budget"); + assert.ok(views[1]!.includes("### Learner") && views[1]!.includes("topic 4:")); + assert.ok(!views[1]!.includes("topic 3:")); // The document header is the session title. assert.ok(views[0]!.startsWith("# Chunky coverage fixture")); @@ -1184,7 +1197,7 @@ describe("the chunked pipeline (extraction coverage)", () => { }); await finisher.runExtraction(chatId); assert.equal(reRunViews.length, 1); - assert.ok(reRunViews[0]!.includes("### Learner · u4") && reRunViews[0]!.includes("### Learner · u5")); + assert.ok(reRunViews[0]!.includes("### Learner") && reRunViews[0]!.includes("topic 4:")); assert.ok(!reRunViews[0]!.includes("### Learner · u1"), "the covered remainder never re-enters the prompt"); assert.equal(ledgers.readCoverage().length, 5, "the re-run closed the gap"); }); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 5208474..4adfa44 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -224,24 +224,18 @@ function fixtureAudit(): AuditResult { name: "Alpha", statement: "Alpha intuition", aliases: ["alpha feel"], - messages: ["a1", "a3"], - evidence: ["fixture evidence"], }, { id: "p2", name: "Beta rule", statement: "Beta rule", aliases: [], - messages: ["a4"], - evidence: ["fixture evidence"], }, { id: "p3", name: "Gamma", statement: "Gamma application", aliases: [], - messages: ["a5"], - evidence: ["fixture evidence"], }, ], edges: [ @@ -249,8 +243,6 @@ function fixtureAudit(): AuditResult { from: "p1", to: "p2", type: "follows", - messages: ["a4"], - evidence: ["fixture evidence"], }, ], }; @@ -282,7 +274,7 @@ function scopedGraph(chatIds: string[]) { } describe("the merger (mergeFindings)", () => { - it("creates nodes from the verdicts with message-granular provenance", () => { + it("creates nodes from the verdicts with chat-level provenance", () => { const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id)); assert.deepEqual(stats, { nodes: 3, edges: 1 }); @@ -293,14 +285,13 @@ describe("the merger (mergeFindings)", () => { assert.match(node.id, /^n_/); assert.ok(node.name.length > 0, "name is required"); assert.equal("kind" in node, false, "no kind field"); - assert.ok(Array.isArray(node.provenance), "message-granular provenance"); + assert.ok(Array.isArray(node.provenance), "provenance attached"); } - // Provenance is message-granular: p1 came up in a1 and a3. + // 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. const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!; - assert.deepEqual(alpha.provenance, [ - { chatId: chat1.id, messageId: "a1" }, - { chatId: chat1.id, messageId: "a3" }, - ]); + assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]); // The follows edge, provenance attached. assert.equal(graph.edges.length, 1); @@ -308,7 +299,7 @@ describe("the merger (mergeFindings)", () => { assert.equal(edge.from, idByStatement.get("Alpha intuition")); assert.equal(edge.to, idByStatement.get("Beta rule")); assert.equal(edge.type, "follows"); - assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "a4" }]); + assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]); // Mapping lines: one per (chat, sourceId). assert.deepEqual( @@ -351,8 +342,6 @@ describe("the merger (mergeFindings)", () => { name: "Alpha", statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings aliases: ["alpha intuition"], - messages: ["a1"], - evidence: ["fixture evidence"], linkTo: alphaId, }, ], @@ -369,7 +358,7 @@ describe("the merger (mergeFindings)", () => { assert.ok(alpha.aliases.includes("Alpha feel"), "the session's phrasing joins the aliases"); assert.ok(alpha.aliases.includes("alpha intuition")); assert.ok( - alpha.provenance.some((pt) => pt.chatId === chat2.id && pt.messageId === "a1"), + alpha.provenance.some((pt) => pt.chatId === chat2.id), "provenance unions", ); // The mapping records the verdict: (chat2, q1) → the linked node. @@ -391,8 +380,6 @@ describe("the merger (mergeFindings)", () => { name: "Vim registers", statement: "Vim registers map single keys to editing commands.", aliases: [], - messages: [], - evidence: ["fixture evidence"], }, ], edges: [], @@ -421,14 +408,12 @@ describe("the merger (mergeFindings)", () => { id: `p${i + 1}`, name: `Cluster node ${i + 1}`, statement, - aliases: [], - messages: [`cluster_msg_${i + 1}`], // birth provenance — the message anchors - evidence: ["fixture evidence"], + aliases: [], // birth provenance — the message anchors })), edges: [ - { from: "p1", to: "p2", type: "follows", messages: [], evidence: ["e"] }, - { from: "p2", to: "p3", type: "follows", messages: [], evidence: ["e"] }, - { from: "p1", to: "p3", type: "follows", messages: [], evidence: ["e"] }, + { from: "p1", to: "p2", type: "follows", }, + { from: "p2", to: "p3", type: "follows", }, + { from: "p1", to: "p3", type: "follows", }, ], }, graphDeps, @@ -477,8 +462,6 @@ describe("the merger (mergeFindings)", () => { name: "Alpha", statement: "Alpha intuition", // IDENTICAL to the existing node's statement aliases: [], - messages: ["u1"], - evidence: ["fixture evidence"], // no linkTo — identity is the auditor's call alone }, ], @@ -502,9 +485,7 @@ describe("the merger (mergeFindings)", () => { id: "p1", name: "Vim normal mode", statement: "Vim starts in Normal mode, where keys are commands.", - aliases: [], - messages: [], // notes carry no message anchors — the file is the provenance - evidence: ["fixture evidence"], + aliases: [], // notes carry no message anchors — the file is the provenance }, ], edges: [], @@ -515,7 +496,7 @@ describe("the merger (mergeFindings)", () => { assert.deepEqual(stats, { nodes: 1, edges: 0 }); const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!; - assert.deepEqual(node.provenance, [], "no message anchors — the mapping line carries the source"); + assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors"); assert.deepEqual(graphStore.readMappings().find((m) => m.chatId === "src_notesfixture"), { chatId: "src_notesfixture", sourceId: "p1", @@ -607,10 +588,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = ].flat()); const auditA: AuditResult = { propositions: [ - { id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"] }, - { id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], messages: ["du1"], evidence: ["e"] }, + { id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], }, + { id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], }, ], - edges: [{ from: "p1", to: "p2", type: "follows", messages: ["du1"], evidence: ["e"] }], + edges: [{ from: "p1", to: "p2", type: "follows", }], }; mergeFindings(dA.id, auditA, graphDeps, sessionSource(dA.id)); // Resolve through THIS session's mappings — the shared ledger holds @@ -633,10 +614,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = ].flat()); const auditB: AuditResult = { propositions: [ - { id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId }, - { id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], messages: ["eu1"], evidence: ["e"] }, + { id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], linkTo: xId }, + { id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], }, ], - edges: [{ from: "q1", to: "q2", type: "follows", messages: ["eu1"], evidence: ["e"] }], + edges: [{ from: "q1", to: "q2", type: "follows", }], }; mergeFindings(dB.id, auditB, graphDeps, sessionSource(dB.id)); const zId = graphStore.readMappings().find((m) => m.chatId === dB.id && m.sourceId === "q2")!.graphNodeId; @@ -673,7 +654,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = assert.equal(x.name, "Shared core"); assert.ok(!x.aliases.includes("shared feel"), "the deleted creator's phrasings are stripped (owner ruling)"); assert.ok(x.aliases.includes("B phrasing") && x.aliases.includes("Shared core, rephrased")); - assert.deepEqual(x.provenance, [{ chatId: dB.id, messageId: "eu1" }], "only dB's provenance remains"); + assert.deepEqual(x.provenance, [{ chatId: dB.id, messageId: "" }], "only dB's provenance remains"); // Y (the orphan) is gone entirely. assert.equal(graph.nodes.some((n) => n.id === yId), false); assert.equal(graphStore.readNodes().some((n) => n.id === yId), false); @@ -681,7 +662,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore); // Edges: dA's assertion X→Y died; dB's X→Z survived. const scoped = scopedGraph([dA.id, dB.id]); - assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "eu1" }] }]); + assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]); // Mappings: dA's audit trail died; dB's stays. const mappings = graphStore.readMappings().filter((m) => [dA.id, dB.id].includes(m.chatId)); assert.deepEqual(mappings, [{ chatId: dB.id, sourceId: "q1", graphNodeId: xId }, { chatId: dB.id, sourceId: "q2", graphNodeId: zId }]);