extractor redesign: chains are the deliverable (owner-approved, goal-first)

This commit is contained in:
amosblomqvist
2026-09-30 02:09:25 +03:00
parent 08e64466ad
commit a3aabb3a57
6 changed files with 431 additions and 164 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
**The async sub-agent protocol (ack-and-steer).** `research` / `demo` / `visual` are server-executed tools: the call resolves immediately mid-turn with a fire-and-forget ack and the teacher keeps teaching; a background runner works in parallel (the client polls `/api/activity` and `/api/jobs`). On completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client — and the **wake pipeline** delivers it: a per-chat serialized queue waits for the turn to end and no question to be pending, then fires a server-side teacher turn that weaves the result into the lesson. Delivery is exactly-once (deterministic steer ids, delivered-flag checks at every claim point); stalled jobs are recovered on read; aborted jobs deliver an abort steer. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate) and renders steers as artifacts — never a bubble. Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (vision-capable model — the `vision` slot in settings).
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the source — the session's teacher prose or a notes chunk — fully trusted, graph-blind; its deliverable is propositions + reasoning chains: premises that jointly entail a conclusion, submitted as chains and expanded to `follows`-edges at the seam, owner redesign 2026-09-30) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools and stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
+2 -2
View File
@@ -341,14 +341,14 @@ flowchart TB
TRIG["trigger seam (turn-service): coverageOf(record, coverageLedger)<br/>— uncovered tokens ≥ graph.extractionChunkTokens (settings)?"] -->|"yes, single-flight per source"| RUN["extraction.runExtraction(chatId)"]
IMPORT["POST /api/graph/import (notes front door)<br/>deterministic src id = sha256(path); content-fp guard skips unchanged re-imports"] -->|"importNotes: shared pipeline, second extractor prompt"| RUN
RUN --> BATCH["batchRuns — greedy token-budgeted batches of oldest uncovered TEACHER-PROSE messages"]
BATCH --> EXT["EXTRACTOR (LLM) — sees ONLY the session markdown view<br/>(tree-blind, graph-blind; shared engine: core/session-markdown.ts)"]
EXT["EXTRACTOR (LLM) — sees ONLY the teacher-prose view (tree-blind, graph-blind)<br/>submits propositions + REASONING CHAINS (owner redesign, 2026-09-30),<br/>expanded to follows-edges at the seam (chainsToEdges, core/practice.ts)"]
EXT --> AUD["AUDITOR (LLM, agentic loop) — sees findings ONLY, never the source;<br/>explores the graph via find / list_sessions / list_propositions tools,<br/>judges identity ONLY (link-or-create, equivalence bias) — never mints edges"]
AUD --> MERGE["MERGER (deterministic, core/graph.ts) — the ONLY ledger writer:<br/>identity via validated linkTo · dedupe edges · coverage checkpoint"]
MERGE --> SETTLE["settleGraphLayout — resting positions ledger"]
MERGE -->|"failure at ANY stage"| NOTHING["checkpoints nothing — the next trigger re-runs"]
```
**Raw reasoning chains only** (2026-09-27 cleanup; teacher-prose view 2026-09-28): the extractor reads ONLY the session — and since the 2026-09-28 ruling, ONLY the teacher agent's prose: plain paragraphs under the session title, no learner messages, no thinking, no tool calls or results, no quiz blocks. Coverage fingerprints, slot tokens, and batch budgets all derive from that same selection (`messageViewLines` in `core/coverage.ts`), so "covered" is exactly "what the extractor saw" — a quiz answer or learner edit can never open a run, and only a teacher-prose edit re-opens its message. The old call+result inseparability bond is deleted (nothing left to keep together); batching is pure greedy fill. The shared markdown engine (`core/session-markdown.ts`) keeps the full session format for the future user-facing export and is no longer used by extraction. The extractor 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 is practice territory — which landed 2026-09-29 as **feedback-only strengthening** (§7): grades live on problem sets, and nothing ever writes learner states back to the graph.
**Raw reasoning chains only** (2026-09-27 cleanup; teacher-prose view 2026-09-28): the extractor reads ONLY the session — and since the 2026-09-28 ruling, ONLY the teacher agent's prose: plain paragraphs under the session title, no learner messages, no thinking, no tool calls or results, no quiz blocks. Coverage fingerprints, slot tokens, and batch budgets all derive from that same selection (`messageViewLines` in `core/coverage.ts`), so "covered" is exactly "what the extractor saw" — a quiz answer or learner edit can never open a run, and only a teacher-prose edit re-opens its message. The old call+result inseparability bond is deleted (nothing left to keep together); batching is pure greedy fill. The shared markdown engine (`core/session-markdown.ts`) keeps the full session format for the future user-facing export and is no longer used by extraction. The extractor's deliverable is propositions + REASONING CHAINS (owner redesign, 2026-09-30): a chain is premises that JOINTLY entail a conclusion — independent arguments for the same conclusion are separate chains, conclusions are reusable by id as premises, and a bare proposition rides the propositions list alone. The wire schema carries propositions (id/name/statement/optional aliases) + chains (premises minItems 1/conclusion) — the `follows`/`supports` edges array is GONE from the wire, and no stage mints a `supports` edge again (supports stay in the contract/store/UI/old data; no migrations). The seam EXPANDS each chain to follows-edges (`chainsToEdges` in `core/practice.ts`, next to its inverse `chainsToGraph` — one math, one place; identical chains dedupe silently there), so `SessionFindings` and everything downstream (auditor, merge, coverage, store) are unchanged. The chain laws are enforced by the practice-proven error-back hook at the submit seam: every premise/conclusion id must resolve to a submitted proposition, premises are non-empty, no self-reference — a violation returns an error string and the model re-submits within its turns. The prompt is GOAL-FIRST, no rule armor (owner philosophy ruling, 2026-09-30): one shared `EXTRACTION_CORE` (the goal, the truth law, the submission mechanics) plus two one-sentence contextualizations — session and notes preambles — assembled at the call sites, so both front doors share the core by construction; the old defensive lists (DROP lists, merge-your-own-output, name walls, when-in-doubt-omit, the faithfulness paragraph) are all cut, and the old opening describing Quiz/Recall-check/Question blocks (stale since the teacher-prose ruling) is simply omitted. A `prepareArguments` heal at the pre-validation seam rescues stringified-payload submits (propositions or chains). Every node is an objective, world-facing fact. No quiz outcomes, no understanding assessments — the events ledger and `UnderstandingLevel` are deleted; the learner's relationship to a proposition is practice territory — which landed 2026-09-29 as **feedback-only strengthening** (§7): grades live on problem sets, and nothing ever writes learner states back to the graph.
**Serialized audit, parallel extracts — per-chunk jobs** (owner ruling 2026-09-28): every chunk (notes file) and every batch (chat coverage) is its own JOB — registry row, usage record, failure domain. Extracts fan out in parallel across all jobs and sources, throttled by a process-wide 3-permit extract semaphore on `globalThis` (the provider-facing rate stance; a job waiting for a permit shows `queued`). The audit→merge section remains a process-wide FIFO critical section — a mutex on `globalThis` (the same module-instance idiom as the run registries): before any auditor LLM call or graph read, a job waits (`waiting` phase) until every earlier job's merge has landed, so each auditor explores the graph as it actually stands — two concurrent audits would each link into a graph the other is about to change, the structural mint of duplicate nodes. Failure isolation: one job failing (bad chunk, degraded model output) is absorbed at the fan-out — its messages stay uncovered and honestly re-runnable while its siblings still merge; the run returns what actually merged. Abort is source-wide: every job of a source's fan-out dies together, and merged jobs keep their checkpoints. The status feed shows one row per job (`file.md · 2/5` suffixes for chunked sources), and each job persists its own usage record (failed jobs bill their partial spend too).
+123 -142
View File
@@ -28,6 +28,7 @@ import type { GraphStore } from "../../core/ports.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
import { teacherProseBlocks } from "../../core/coverage.ts";
import { chainsToEdges } from "../../core/practice.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent } from "./submit-agent.ts";
@@ -38,11 +39,12 @@ import { runSubmitAgent } from "./submit-agent.ts";
* orchestrator:
*
* extractor (LLM) the ONE stage that sees the raw source (a session
* rendered as markdown by the shared session-markdown
* engine, or a notes file verbatim) → SessionFindings:
* propositions + follows/supports edges, each with
* TREE-BLIND, graph-blind, FULLY
* TRUSTED — nothing downstream re-checks it.
* rendered as the teacher's prose, or a notes file
* verbatim) → submits propositions + REASONING CHAINS
* (owner redesign, 2026-09-30), expanded at the seam to
* SessionFindings' follows-edges. TREE-BLIND,
* graph-blind, FULLY TRUSTED — nothing downstream
* re-checks it.
* auditor (LLM) never sees the source. Its job is IDENTITY: for each
* proposition, linkTo an existing graph node or create —
* decided by EXPLORING the graph through tools (find /
@@ -115,33 +117,38 @@ function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
// ─── the extractor ──────────────────────────────────────────────────────────
const propositionProps = {
id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by edges.' }),
id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by chains.' }),
name: Type.String({
description:
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
"('Will to power', 'Divine command theory'). Not a sentence, needn't be unique or " +
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
"A very short label — established terminology, the term the discipline itself uses, a glossary " +
"headword a textbook would print ('Will to power', 'Divine command theory'). Not a sentence, " +
"needn't be unique or self-contained: this becomes the node's global label in the graph.",
}),
statement: Type.String({
description:
"The proposition as one reasoning step — a TRUE, self-contained, world-facing statement. " +
'Never reference the learner, the teacher, or the session: not "the learner understands X" but ' +
'"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.',
"One true, self-contained, world-facing reasoning step at full precision. Never about the " +
'learner, the teacher, or the reading: not "the learner understands X" but "X". A misconception ' +
"enters as its TRUE correction.",
}),
aliases: Type.Optional(
Type.Array(Type.String(), {
description: "Verbatim phrasings this session used for the same proposition, if any.",
description: "Verbatim phrasings the source used for the same proposition, if any.",
}),
),
};
const edgeSchema = Type.Object({
from: Type.String({ description: "Proposition id that is followed / supported." }),
to: Type.String({ description: "Proposition id that follows / is supported by `from`." }),
type: Type.Union([Type.Literal("follows"), Type.Literal("supports")], {
description: '"follows" = to is derivable from from; "supports" = from grounds to without entailing it.',
// The extractor redesign (owner, 2026-09-30): the extractor submits
// REASONING CHAINS — premises + conclusion — and the seam expands each
// premise to one follows-edge (chainsToEdges, core/practice.ts). The
// old follows/supports edges array is GONE from the wire; supports stay
// in the graph contract/store/UI/old data, but no stage mints one again.
const chainSchema = Type.Object({
premises: Type.Array(Type.String(), {
minItems: 1,
description:
"Proposition ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
}),
conclusion: Type.String({ description: "The proposition id the premises jointly imply." }),
});
// Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` /
@@ -151,16 +158,24 @@ const edgeSchema = Type.Object({
// no zod left in the agent band.
const findingsProps = {
propositions: Type.Array(Type.Object(propositionProps), {
description: "Every distinct proposition the session touched — true statements, misconception-free.",
description: "The propositions themselves — every distinct proposition the source's reasoning uses.",
}),
edges: Type.Array(edgeSchema, {
chains: Type.Array(chainSchema, {
description:
"Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.",
"Every argument the source makes; conclusions reusable by id as premises of later chains; " +
"bare propositions (no argument) ride the propositions list alone; pass [] when none — " +
"never omit the field.",
}),
};
const findingsSchema = Type.Object(findingsProps);
/** What the extractor SUBMITS (the redesign's wire shape) — propositions
* + chains. fillFindings normalizes it; validateFindingsChains checks
* the chain laws; expandFindings translates it to SessionFindings at
* the seam. */
type FindingsSubmit = Static<typeof findingsSchema>;
// 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
@@ -198,7 +213,7 @@ type NotesAuditResult = AuditVerdicts;
function healStringifiedArrays(raw: unknown): unknown {
if (typeof raw !== "object" || raw === null) return raw;
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
for (const key of ["propositions", "edges"]) {
for (const key of ["propositions", "chains"]) {
const v = out[key];
if (typeof v !== "string" || v.trim().length === 0) continue;
try {
@@ -211,20 +226,55 @@ function healStringifiedArrays(raw: unknown): unknown {
return out;
}
// ── the submit seam (owner, 2026-09-28) ──
// ── the submit seam (owner, 2026-09-28; redesigned 2026-09-30) ──
//
// Every top-level field on each submit tool is REQUIRED — a payload
// missing `edges` (findings) or `links` (review) fails validation instead
// of silently merging
// edge-less (the degraded-retry hole: the model's first truncated
// attempt failed validation, then the retry passed with propositions
// only — optional edges ??-filled [] — merging a pile of nodes with no
// structure). Only the per-item aliases stay optional with a ??-fill.
// missing `chains` (findings) or `links` (review) fails validation
// instead of silently merging structure-less (the degraded-retry hole:
// the model's first truncated attempt failed validation, then the retry
// passed with propositions only — optional fields ??-filled [] — merging
// a pile of nodes with no structure). Only the per-item aliases stay
// optional with a ??-fill.
function fillFindings(raw: Static<typeof findingsSchema>): SessionFindings {
function fillFindings(raw: FindingsSubmit): FindingsSubmit {
return {
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
edges: raw.edges,
chains: raw.chains,
};
}
/** The chain laws (extractor redesign, 2026-09-30) — the LOUD channel:
* runSubmitAgent turns a problem string into an error tool result and
* the model re-submits within its turns (the practice-proven hook).
* Identical (premise-set, conclusion) chains are NOT errors — they
* dedupe silently at the expansion (chainsToEdges): restating an
* argument is a no-op. */
function validateFindingsChains(value: FindingsSubmit): string | null {
const ids = new Set(value.propositions.map((p) => p.id));
for (const chain of value.chains) {
if (chain.premises.length === 0) {
return "chains: premises must name at least one proposition — a proposition with no argument stays in the propositions list alone.";
}
const unknown = [...chain.premises, chain.conclusion].find((id) => !ids.has(id));
if (unknown !== undefined) {
return `chains: "${unknown}" is not one of the submitted proposition ids — every premise and conclusion must appear in this call's propositions.`;
}
if (chain.premises.includes(chain.conclusion)) {
return "chains: a conclusion cannot be its own premise.";
}
}
return null;
}
/** The translation seam (extractor redesign, 2026-09-30): the wire
* speaks CHAINS, the pipeline speaks follows-EDGES — SessionFindings
* and everything downstream (auditor, resolution, merge, coverage,
* store) is UNCHANGED. One math, one place: chainsToEdges sits next
* to chainsToGraph in core/practice.ts. */
function expandFindings(raw: FindingsSubmit): SessionFindings {
return {
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
edges: chainsToEdges(raw.chains),
};
}
@@ -234,117 +284,43 @@ function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts {
};
}
const NOTES_EXTRACTOR_INSTRUCTIONS = `You are the extraction specialist in a learning application. You read ONE source —
the user's own NOTES, in an ARBITRARY format (personal notes, an exported conversation, a document — anything) — and
report the arguments and reasoning those notes actually develop, as structured findings for their global knowledge graph.
// ── the extractor's system prompt (owner redesign, 2026-09-30) ──
//
// GOAL-FIRST, NO RULE ARMOR: the old doctrine's defensive lists (DROP
// lists, merge-your-own-output, name walls, when-in-doubt-omit, the
// faithfulness paragraph) are ALL CUT — the prompt states the goal and
// the submission mechanics; the tool's field descriptions carry the
// details. One shared core + two contextualizations, assembled here —
// session and notes share the core by construction. (The old opening
// describing Quiz/Recall-check/Question blocks was stale since the
// 2026-09-28 teacher-prose ruling and is simply omitted.)
There is no session and no chat structure here: the source is raw text, the author's own material. Read it as such.
You are the ONLY stage that sees this source — you are fully trusted; report carefully.
You may be seeing ONE PART of a longer file (large files are chunked): extract exactly what THIS part holds —
the auditor links equivalent propositions across parts, so overlap worries are not yours.
export const SESSION_PREAMBLE =
"You are the extraction stage of a learning application. You read one source — a teaching session, " +
"rendered as the teacher's prose in reading order — and report what it teaches as findings for the " +
"learner's global knowledge graph.";
The graph is a set of TRUE propositions about the world, full stop. A node is never anything about the author or the
source — no "the author understands X", no "mentioned in these notes": the node is just X. A misconception is NOT a
node of its own wrong claim — state the TRUE correction as the proposition.
export const NOTES_PREAMBLE =
"You are the extraction stage of a learning application. You read one source — one chunk of the " +
"author's personal notes, rendered in reading order — and report what it teaches as findings for the " +
"learner's global knowledge graph.";
1. PROPOSITIONS — only the propositions that CARRY the notes' reasoning. A proposition earns its
place by being a premise, an intermediate step, or a conclusion of an argument the notes actually
make. The graph is the reasoning skeleton of the source — not an archive of everything said:
- DROP: isolated facts with no argumentative role, name-drops and citations, one-line asides,
trivia, enumerations of examples — however true. A proposition nobody argues from, argues to,
or argues against does not belong.
- Each has a "name" — a VERY short label, a few words: ESTABLISHED terminology, the term the discipline
itself uses, a glossary headword a textbook would print ("Will to power", "Divine command theory").
NEVER source-local bookkeeping — no letters, numbering, or positional labels ("F1", "criterion C",
"the second objection"): the name becomes a GLOBAL graph label that someone who never saw these
notes must parse. If the notes used a local shorthand for an established idea, name the node by the
established term and put the shorthand in "aliases".
- And a "statement" — the self-contained TRUE proposition at full precision.
- MERGE YOUR OWN OUTPUT before submitting: two propositions stating the SAME claim at different
precision, granularity, or angle are ONE proposition — one statement at full precision, the other
phrasings in "aliases". Nothing downstream dedupes you; duplicates become duplicate graph nodes.
- Closely related but genuinely distinct propositions stay separate; whether they connect is
section 2's call — never add a proposition or an edge just to avoid a lonely node.
- Notes may explain at length or quote studied material: extract the propositions that carry the
reasoning, one step each — never a topic blob, never a sub-step.
- "aliases": verbatim phrasings the notes used for the same proposition.
export const EXTRACTION_CORE = `THE GOAL: extract the source's REASONING CHAINS. The source teaches by making arguments — propositions
worked out from other propositions. Your deliverable is twofold: the propositions themselves, and every
argument the source makes. Each argument is a chain: premises that JOINTLY entail a conclusion.
2. EDGES between propositions — PRECISION over recall. An edge exists ONLY where the source itself
presents one proposition as following from, depending on, or being grounded in another — an
explicit argumentative act in the text, never a felt relatedness:
- "follows": to is DERIVABLE from from (understanding from makes understanding to achievable).
- "supports": from GROUNDS to — the source EXPLICITLY presents from as evidence, motivation, or
basis for to, without entailing it. If you have to reach to see the grounding, the edge does not
exist. "supports" is the rare exception; a genuinely argumentative relation is almost always
"follows".
- WHEN IN DOUBT, OMIT. A sparse, trustworthy graph beats a dense, vague one. Never connect
propositions merely because they are related, mentioned together, or about the same topic —
that is how a graph turns to mush.
The graph holds TRUE propositions about the world, nothing else — never anything about the learner, the
teacher, or this reading: not "the learner understands X" but X. A misconception enters as its TRUE
correction. Each statement is one self-contained reasoning step at full precision.
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.
SUBMIT with one submit_findings call: every proposition with a short name and a precise statement, and
every chain as premises + conclusion. Premises within one chain are used TOGETHER — independent arguments
for the same conclusion are separate chains. A conclusion may reappear, by id, as a premise of later
chains. Include every field even when empty — "chains": [] when the source makes no arguments. The
tool's field descriptions carry the details.`;
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.
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, 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.
The graph is a set of TRUE propositions about the world, full stop. A node is never anything about the learner or the
session — no "the learner understands X", no "discussed in this session": the node is just X. A misconception is NOT
a node of its own wrong claim — state the TRUE correction as the proposition.
1. PROPOSITIONS — only the propositions that CARRY the session's reasoning. A proposition earns its
place by being a premise, an intermediate step, or a conclusion of an argument the session actually
makes. The graph is the reasoning skeleton of the source — not an archive of everything said:
- DROP: isolated facts with no argumentative role, name-drops and citations, one-line asides,
trivia, enumerations of examples — however true. A proposition nobody argues from, argues to,
or argues against does not belong.
- Each has a "name" — a VERY short label, a few words: ESTABLISHED terminology, the term the discipline
itself uses, a glossary headword a textbook would print ("Will to power", "Divine command theory").
NEVER session-local bookkeeping — no letters, numbering, or positional labels ("F1", "criterion C",
"the second objection"): the name becomes a GLOBAL graph label that someone who never saw this
session must parse. If the session used a local shorthand for an established idea, name the node by
the established term and put the shorthand in "aliases".
- And a "statement" — the self-contained TRUE proposition at full precision (the elaboration lives
in the graph only as this one statement).
- MERGE YOUR OWN OUTPUT before submitting: two propositions stating the SAME claim at different
precision, granularity, or angle are ONE proposition — one statement at full precision, the other
phrasings in "aliases". Nothing downstream dedupes you; duplicates become duplicate graph nodes.
- Closely related but genuinely distinct propositions stay separate; whether they connect is
section 2's call — never add a proposition or an edge just to avoid a lonely node.
- Corrections of misconceptions the teacher articulated are argumentative by nature — always in.
- One reasoning step per proposition: never a topic blob, never a sub-step.
- "aliases": verbatim phrasings this session used for the same proposition.
2. EDGES between propositions — PRECISION over recall. An edge exists ONLY where the source itself
presents one proposition as following from, depending on, or being grounded in another — an
explicit argumentative act in the text, never a felt relatedness:
- "follows": to is DERIVABLE from from (understanding from makes understanding to achievable).
- "supports": from GROUNDS to — the source EXPLICITLY presents from as evidence, motivation, or
basis for to, without entailing it. If you have to reach to see the grounding, the edge does not
exist. "supports" is the rare exception; a genuinely argumentative relation is almost always
"follows".
- WHEN IN DOUBT, OMIT. A sparse, trustworthy graph beats a dense, vague one. Never connect
propositions merely because they are related, mentioned together, or about the same topic —
that is how a graph turns to mush.
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.
Your entire deliverable is ONE submit_findings call.`;
export const EXTRACTOR_INSTRUCTIONS = `${SESSION_PREAMBLE}\n\n${EXTRACTION_CORE}`;
export const NOTES_EXTRACTOR_INSTRUCTIONS = `${NOTES_PREAMBLE}\n\n${EXTRACTION_CORE}`;
/** The extractor over ONE session transcript (the chat front door). */
function runExtractor(
@@ -353,12 +329,16 @@ function runExtractor(
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SessionFindings> {
return runSubmitAgent({
// Chains in (the wire), follows-edges out (the pipeline) — the
// translation seam of the 2026-09-30 redesign. Validation runs at the
// submit seam (error-back); expansion runs once, after a clean submit.
return runSubmitAgent<FindingsSubmit>({
role: "extractor",
toolName: "submit_findings",
toolDescription: "Submit the session findings.",
parameters: findingsSchema,
fill: fillFindings,
validate: validateFindingsChains,
systemPrompt: EXTRACTOR_INSTRUCTIONS,
prompt,
engine,
@@ -366,7 +346,7 @@ function runExtractor(
onStep,
neverSubmitted: "the extractor never submitted findings",
prepareArguments: healStringifiedArrays,
});
}).then(expandFindings);
}
/** The extractor over ONE source of raw personal notes (the format-agnostic
@@ -377,12 +357,13 @@ function runNotesExtractor(
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<NotesFindings> {
return runSubmitAgent({
return runSubmitAgent<FindingsSubmit>({
role: "extractor",
toolName: "submit_findings",
toolDescription: "Submit the notes findings.",
parameters: findingsSchema,
fill: fillFindings,
validate: validateFindingsChains,
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
prompt,
engine,
@@ -390,7 +371,7 @@ function runNotesExtractor(
onStep,
neverSubmitted: "the extractor never submitted findings",
prepareArguments: healStringifiedArrays,
});
}).then(expandFindings);
}
// ─── the auditor ───────────────────────────────────────────────────────────
@@ -398,7 +379,7 @@ function runNotesExtractor(
const AUDITOR_INSTRUCTIONS = `You are the linking stage of a learning application's extraction pipeline. An extractor
has read a source (a teaching session, or the author's personal notes) and reported findings: propositions and
follows/supports edges. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check
the follows edges of their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check
its work. Your job is IDENTITY: connecting those findings to the CURRENT knowledge graph, preparing the merge.
You explore the graph through your tools: list_sessions, list_propositions, and find (glob over names +
+23 -1
View File
@@ -73,7 +73,7 @@ export function chainsForSelection(
* edges = one follows-edge per (premise, conclusion) pair. Deterministic:
* node ids sorted, edges sorted by (from, to). */
export function chainsToGraph(
chains: ReadonlyArray<Pick<ReasoningChain, "id" | "premises" | "conclusion">>,
chains: ReadonlyArray<Pick<ReasoningChain, "premises" | "conclusion">>,
): {
nodeIds: string[];
edges: Array<{ from: string; to: string; type: "follows" }>;
@@ -91,6 +91,28 @@ export function chainsToGraph(
return { nodeIds: [...nodeIds].sort(), edges };
}
/** The extraction seam's chain→edge expansion (extractor redesign,
* 2026-09-30): the extractor SUBMITS chains (premises + conclusion);
* the pipeline's internal findings carry follows-EDGES downstream
* (auditor, merger, coverage — all unchanged). Each premise becomes
* one edge into the conclusion — chainsToGraph's edge law, shared
* (one math, one place). Identical (premise-set, conclusion) chains
* dedupe SILENTLY here: the model restating an argument is a no-op,
* never an error (validation errors are the loud channel; see the
* extractor's validate hook). */
export function chainsToEdges(
chains: ReadonlyArray<Pick<ReasoningChain, "premises" | "conclusion">>,
): Array<{ from: string; to: string; type: "follows" }> {
const seen = new Set<string>();
const deduped = chains.filter((chain) => {
const key = `${[...chain.premises].sort().join("+")}>${chain.conclusion}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return chainsToGraph(deduped).edges;
}
/** A chain as one world-facing line — the propositions' NAMES joined
* by the derivation arrow: "Premise one, Premise two → Conclusion"; a
* singleton shows its "(no premises)" marker. Pure + browser-safe:
+246 -17
View File
@@ -41,7 +41,17 @@ import type {
SessionFindings,
} from "../src/core/contracts.ts";
import type { GraphStore } from "../src/core/ports.ts";
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
import {
abortExtraction,
createExtractionPipeline,
extractionStatuses,
transcriptView,
EXTRACTION_CORE,
SESSION_PREAMBLE,
NOTES_PREAMBLE,
EXTRACTOR_INSTRUCTIONS,
NOTES_EXTRACTOR_INSTRUCTIONS,
} from "../src/agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
import { coverageOf, messageViewLines } from "../src/core/coverage.ts";
import { chatStore } from "../src/store/chats.ts";
@@ -217,9 +227,10 @@ after(() => {
}
});
/** What the extractor would produce for the fixture session — tree-blind,
* free-form, with chat-level provenance. */
const FINDINGS: SessionFindings = {
/** 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. */
const FINDINGS = {
propositions: [
{
id: "p1",
@@ -234,11 +245,10 @@ const FINDINGS: SessionFindings = {
aliases: [],
},
],
edges: [
chains: [
{
from: "p1",
to: "p2",
type: "follows",
premises: ["p1"],
conclusion: "p2",
},
],
};
@@ -371,7 +381,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
// heal parses it, validation passes, no retry burned.
const extractor = submitModel("submit_findings", {
propositions: JSON.stringify(FINDINGS.propositions),
edges: FINDINGS.edges,
chains: FINDINGS.chains,
});
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
@@ -379,6 +389,22 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
assert.deepEqual(stats, { nodes: 2, edges: 1 });
});
it("stringified-payload heal: the CHAINS array arriving as ONE JSON string still validates (redesign, 2026-09-30)", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const extractor = submitModel("submit_findings", {
propositions: FINDINGS.propositions,
chains: JSON.stringify(FINDINGS.chains),
});
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor,
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
});
it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
@@ -661,9 +687,9 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const b = fixtureChat();
const ledgers = memoryLedgers();
const single = (id: string, name: string, statement: string): SessionFindings => ({
const single = (id: string, name: string, statement: string) => ({
propositions: [{ id, name, statement, aliases: [], }],
edges: [],
chains: [],
});
const findingsA = single("pa", "Alpha claim", "Alpha statement, full precision");
const findingsB = single("pb", "Beta claim", "Beta statement, full precision");
@@ -718,9 +744,9 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const c = fixtureChat();
const ledgers = memoryLedgers();
const single = (id: string, name: string, statement: string): SessionFindings => ({
const single = (id: string, name: string, statement: string) => ({
propositions: [{ id, name, statement, aliases: [], }],
edges: [],
chains: [],
});
let gateOpen!: () => void;
@@ -962,8 +988,9 @@ const NOTES_TEXT = `> [!quote] YOU
> [!abstract] PI
> Vim always starts in Normal mode — keys are commands, not text.`;
/** What the notes-mode extractor submits: same shapes as session findings,
* messages always empty (the file is the provenance). */
/** What the notes-mode extractor submits: same wire shape as session
* findings (propositions + chains), messages always empty (the file is
* the provenance). */
const NOTES_FINDINGS = {
propositions: [
{
@@ -973,7 +1000,7 @@ const NOTES_FINDINGS = {
aliases: [],
},
],
edges: [],
chains: [],
} as const;
const NOTES_VERDICT = {
@@ -981,6 +1008,208 @@ const NOTES_VERDICT = {
edges: [],
} as const;
// ─── the extractor redesign (owner, 2026-09-30) ─────────────────────────────
describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable", () => {
/** A retry engine: step 1 submits the BAD payload (schema-valid, law-
* violating — so the pin proves the VALIDATE channel fired, not the
* schema), step 2 submits the clean one. */
function retryEngine(bad: unknown, good: unknown): PiEngine {
return fauxEngine([
() =>
fauxAssistantMessage([fauxToolCall("submit_findings", bad as ToolCall["arguments"], { id: "findings-bad" })], {
stopReason: "toolUse",
}),
() =>
fauxAssistantMessage([fauxToolCall("submit_findings", good as ToolCall["arguments"], { id: "findings-good" })], {
stopReason: "toolUse",
}),
]);
}
it("co-premises fan out at the seam: ONE chain with two premises becomes TWO follows-edges into the conclusion", async () => {
const { chatId } = fixtureChat();
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: [] },
],
chains: [{ premises: ["p1", "p2"], conclusion: "p3" }],
});
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor,
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, edges: 2 });
// Both edges point INTO the conclusion's resolved node; the two
// from-ends are exactly the two premises (node ids are random —
// assert set-wise, never by ledger order).
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
const conclusion = byName.get("C")!;
const edges = ledgers.readEdges();
assert.ok(edges.every((e) => e.to === conclusion), "both edges point INTO the conclusion");
assert.deepEqual(
edges.map((e) => e.from).sort(),
[byName.get("A")!, byName.get("B")!].sort(),
);
});
it("two chains sharing a conclusion union their premises; a bare proposition stays an edge-less node", async () => {
const { chatId } = fixtureChat();
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: [] },
],
chains: [
{ premises: ["p1"], conclusion: "p3" },
{ premises: ["p2"], conclusion: "p3" },
],
});
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor,
auditor: submitModel("submit_review", { links: [] }),
});
// Independent arguments for the same conclusion are separate chains
// → two edges into C; the bare p4 rides the propositions list alone.
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, edges: 2 });
const lonely = ledgers.readNodes().find((n) => n.name === "Lonely")!;
assert.ok(ledgers.readEdges().every((e) => e.from !== lonely.id && e.to !== lonely.id));
});
it("identical chains dedupe SILENTLY at the expansion — restating an argument is a no-op, not an error", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const extractor = submitModel("submit_findings", {
...FINDINGS,
chains: [FINDINGS.chains[0]!, FINDINGS.chains[0]!],
});
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor,
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
});
it("the OLD wire shape (an edges array, no chains) is dead: validation fails every turn — nothing merges", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const oldShape = () =>
fauxEngine(
Array.from({ length: 4 }, () => () =>
fauxAssistantMessage(
[
fauxToolCall(
"submit_findings",
{
propositions: FINDINGS.propositions,
edges: [{ from: "p1", to: "p2", type: "follows" }],
} as unknown as ToolCall["arguments"],
),
],
{ stopReason: "toolUse" },
),
),
);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
engineFor: (role) => (role === "extractor" ? oldShape() : submitModel("submit_review", { links: [] })),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 });
});
it("validate error-back: a chain referencing an UNKNOWN id is rejected, the model re-submits clean", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const bad = {
...FINDINGS,
chains: [{ premises: ["p1", "p9"], conclusion: "p2" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
auditor: submitModel("submit_review", { links: [] }),
});
// The first submit was schema-VALID — only the chain law caught it;
// the retry merges in full.
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
});
it("validate error-back: a conclusion in its OWN premises is rejected, the model re-submits clean", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const bad = {
...FINDINGS,
chains: [{ premises: ["p1", "p2"], conclusion: "p2" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
});
it("empty premises never pass: the schema's minItems rejects, the model re-submits clean", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const bad = {
...FINDINGS,
chains: [{ premises: [], conclusion: "p2" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
extractor: retryEngine(bad, FINDINGS),
auditor: submitModel("submit_review", { links: [] }),
});
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
});
it("the system prompt is goal-first: ONE shared core, both preambles assembled onto it", () => {
// The core carries the goal + submission mechanics.
assert.match(EXTRACTION_CORE, /JOINTLY entail a conclusion/);
assert.match(EXTRACTION_CORE, /for the same conclusion are separate chains/);
assert.match(EXTRACTION_CORE, /SUBMIT with one submit_findings call/);
assert.match(EXTRACTION_CORE, /"chains": \[\] when the source makes no arguments/);
// Assembly: each front door = its preamble + the shared core.
assert.ok(EXTRACTOR_INSTRUCTIONS.startsWith(SESSION_PREAMBLE));
assert.ok(EXTRACTOR_INSTRUCTIONS.includes(EXTRACTION_CORE));
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 preambles differ in exactly their source description.
assert.notEqual(SESSION_PREAMBLE, NOTES_PREAMBLE);
assert.match(SESSION_PREAMBLE, /a teaching session, rendered as the teacher's prose in reading order/);
assert.match(NOTES_PREAMBLE, /one chunk of the author's personal notes, rendered in reading order/);
// NO RULE ARMOR (owner, 2026-09-30): the defensive lists are CUT
// from both contextualizations.
for (const prompt of [EXTRACTOR_INSTRUCTIONS, NOTES_EXTRACTOR_INSTRUCTIONS]) {
assert.doesNotMatch(prompt, /DROP:/);
assert.doesNotMatch(prompt, /MERGE YOUR OWN OUTPUT/);
assert.doesNotMatch(prompt, /WHEN IN DOUBT, OMIT/);
assert.doesNotMatch(prompt, /Faithfulness:/);
assert.doesNotMatch(prompt, /follows\/supports/);
}
});
});
describe("the notes front door (importNotes — format-agnostic)", () => {
it("imports raw notes end to end: source record, file-scoped provenance", async () => {
const ledgers = memoryLedgers();
@@ -1065,7 +1294,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
assert.equal(ledgers.readNodes().length, calls);
});
it("required top-level fields (owner, 2026-09-28): a findings submit MISSING edges fails validation — no silent edge-less merge", async () => {
it("required top-level fields (owner, 2026-09-28; chains since 2026-09-30): a findings submit MISSING chains fails validation — no silent structure-less merge", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// The degraded-retry shape from the OpenRouter log: propositions
+36 -1
View File
@@ -9,7 +9,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { EdgeType } from "../src/core/contracts.ts";
import { chainCoverage, chainsForSelection, chainsToGraph, formatChainLine } from "../src/core/practice.ts";
import { chainCoverage, chainsForSelection, chainsToEdges, chainsToGraph, formatChainLine } from "../src/core/practice.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
@@ -159,3 +159,38 @@ describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () =>
assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
});
});
// ─── chainsToEdges — the extraction seam's expansion (2026-09-30) ────────────
describe("chainsToEdges (the extractor redesign's translation seam)", () => {
it("each premise becomes ONE follows-edge into the conclusion", () => {
const edges = chainsToEdges([{ premises: ["p1", "p2"], conclusion: "c1" }]);
assert.deepEqual(edges, [
{ from: "p1", to: "c1", type: "follows" },
{ from: "p2", to: "c1", type: "follows" },
]);
});
it("chains sharing a conclusion union their premises; the result stays sorted", () => {
const edges = chainsToEdges([
{ premises: ["p2"], conclusion: "c1" },
{ premises: ["p1"], conclusion: "c1" },
]);
assert.deepEqual(edges, [
{ from: "p1", to: "c1", type: "follows" },
{ from: "p2", to: "c1", type: "follows" },
]);
});
it("identical (premise-set, conclusion) chains dedupe silently — restating is a no-op", () => {
const chain = { premises: ["p1", "p2"], conclusion: "c1" };
assert.deepEqual(chainsToEdges([chain, chain, { premises: ["p2", "p1"], conclusion: "c1" }]), [
{ from: "p1", to: "c1", type: "follows" },
{ from: "p2", to: "c1", type: "follows" },
]);
});
it("empty in, empty out — bare propositions ride the propositions list alone", () => {
assert.deepEqual(chainsToEdges([]), []);
});
});