From bdd9cac2b76b331f642f4587a2d0395a1b75831b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 00:40:02 +0300 Subject: [PATCH] Node schema pivot: short name (label) + full-precision statement; description dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With real extracted data, full-sentence statements as canvas labels proved unworkable: 10-line label blocks, illegible overprint clusters, bottom-edge clipping. Per the user's 2026-09-08 decision: - GraphNode/ExtractedProposition gain `name` — a very short, glanceable label (glossary-headword style: "Will to power"), presentational only: not unique, not identity, never precise. Set at creation; link-appends never change it (first line defines; labels stay stable across sessions). - `statement` keeps its full precision and becomes the identity anchor; the detail card shows it under the name. `description` is dropped. - Extractor coins the name (freshest session context); auditor carries it through on create, rewording only clearly unglanceable names; linked propositions keep the existing node's name. - Renderer: canvas label = name; search matches name + statement; detail card = name (bold) + statement (muted). - Store guard requires name (pre-pivot node lines are skipped as legacy); vision.md node-schema bullet + amendments table updated (2026-09-08). --- components/graph-view.tsx | 31 ++++++++++++++------------- docs/vision.md | 3 ++- src/agent/subagents/extraction.ts | 30 ++++++++++++++++---------- src/core/contracts/graph.ts | 35 +++++++++++++++++++------------ src/core/graph.ts | 11 ++++++---- src/store/graph.ts | 2 +- tests/extraction.test.ts | 8 +++---- tests/graph-context.test.ts | 14 ++++++------- tests/graph-view-filters.test.ts | 13 ++++++++---- tests/graph.test.ts | 26 ++++++++++++----------- 10 files changed, 101 insertions(+), 72 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 8bb2209..934d192 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -10,21 +10,22 @@ * and cancelled on unmount — idle CPU stays near zero. Physics is * placement only: pan/zoom, no dragging, no editing. * - * Labels render the node `statement` (the minimal proposition — identity - * key + rendering label; the full `description` lives on the detail card) - * through custom canvas drawers that replace sigma's defaults: + * Labels render the node `name` — the very short, glanceable label the + * extractor coins (a glossary-headword-style handle); the full-precision + * `statement` lives on the detail card. Custom canvas drawers replace + * sigma's defaults: * - defaultDrawNodeLabel: plain wrapped text, centered below the node, * word-wrapped at a max line width — no truncation, no one-line sprawl. * - defaultDrawNodeHover: NO background box (sigma's default is a * hardcoded white one). Instead the hovered node gets a soft glow * halo in its own state color at a constant radius, flush with the - * disc edge, and its statement brightens — the "this is clickable, + * disc edge, and its name brightens — the "this is clickable, * click for the full fact" affordance. Entering/leaving hover fades * ONE transition factor shared by halo and text alike, so the * halo's opacity and the text's muted→bright cross-fade stay in * sync (no growth, no pulse; interpolating from the current value * means rapid hover toggles glide instead of snapping). Hover - * always shows the statement, even when labels are zoomed out. + * always shows the name, even when labels are zoomed out. * Node hit-testing is the disc PLUS a pad: sigma picks nodes from a * half-resolution framebuffer (no v3 setting enlarges hitboxes), and the * glow halo is bigger than the stock hitbox — touching the halo @@ -82,11 +83,11 @@ const STATES_ORDER: NodeStateClass[] = ["taught", "known", "missed", "dont-know" /** * Pure filter predicate for the legend/filters panel: a node is visible - * when its state is toggled on AND (no query OR statement matches). + * when its state is toggled on AND (no query OR name/statement matches). * Exported for tests; the sigma reducers just consume its verdict. */ export function isNodeVisible( - node: Pick, + node: Pick, state: NodeStateClass, hiddenStates: ReadonlySet, query: string, @@ -94,7 +95,7 @@ export function isNodeVisible( if (hiddenStates.has(state)) return false; const q = query.trim().toLowerCase(); if (!q) return true; - return node.statement.toLowerCase().includes(q) || node.description.toLowerCase().includes(q); + return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } // ---- Label drawing (canvas) --------------------------------------------- @@ -102,7 +103,7 @@ export function isNodeVisible( // sigma's defaults draw the label on ONE line to the right of the node with // a white box on hover. We replace both: names wrap under the node at a max // line width, muted at rest; on hover the node pulses a soft glow in its -// own state color and the statement brightens (no background box). +// own state color and the name brightens (no background box). const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line @@ -227,7 +228,7 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu return lines; } -/** The wrapped node statement, centered below the node — used by both drawers +/** The wrapped node name (the short label), centered below the node — used by both drawers * with identical geometry so hover only changes the color, nothing jumps. */ function drawName( context: CanvasRenderingContext2D, @@ -254,7 +255,7 @@ function drawName( * 1 = fully hovered): a glow halo in the node's own state color at a * CONSTANT radius (opacity fades in/out; the halo never grows) starting * flush at the disc edge (the evenodd punch-out keeps the disc itself - * unpainted). The statement is drawn at partial alpha in the bright hover + * unpainted). The name is drawn at partial alpha in the bright hover * color — the muted resting label on the layer beneath shows through, * so text and halo cross-fade on the SAME factor and stay in sync. */ function paintHover( @@ -278,7 +279,7 @@ function paintHover( context.fill("evenodd"); } // Same fade factor as the halo: text and glow rise together. The - // bright statement at partial alpha over the muted resting label beneath is + // bright name at partial alpha over the muted resting label beneath is // a smooth muted→bright cross-fade rather than an instant color swap. context.globalAlpha = fade; drawName(context, data, settings, hoverTextColor); @@ -435,7 +436,7 @@ export function GraphView() { // frozen layout stable for a given node set across remounts. const rand = seededRandom(node.id); graph.addNode(node.id, { - label: node.statement, + label: node.name, size: STATE_TOKENS[state].size, color: stateColors.get(state) ?? edgeColor, x: (rand() - 0.5) * 100, @@ -695,8 +696,8 @@ export function GraphView() { style={{ visibility: "hidden", left: 0, top: 0 }} className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg" > -
{selected.node.statement}
-
{selected.node.description}
+
{selected.node.name}
+
{selected.node.statement}
{STATE_LABELS[selected.state]}
diff --git a/docs/vision.md b/docs/vision.md index 695d9b4..d722f9f 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -21,7 +21,7 @@ The graph updates **continuously, not per-session**. A learner who answers a few The global graph is an **objective world-model of the domains the learner has encountered**: the facts, their structure (what follows from / is supported by what), and — inferred from that structure plus the event ledger — the state of the learner's understanding. Nothing learner- or session-relative is ever stored **in** the graph; understanding is **inferred, never encoded**. - **Every node is a true proposition.** Worded objectively, in minimal form as it exists w.r.t. its context. Truth is context-relative and that is fine: "Gravity is a force" and "Gravity is not a force" coexist as nodes — each true in its context (classical mechanics vs. GR), with **the surrounding subgraph as the context**. No context field, no pedantic self-containment of facts. -- **Node schema:** `statement` — the proposition in minimal form (a short sentence; the rendering label and identity key); `description` — the elaborated version (required); `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** field. +- **Node schema:** `name` — a very short label for the node, a compact handle like a glossary headword (“Will to power”, “Divine command theory”): what the graph shows at a glance. Presentational only — not unique, not an identity key, never precise. Set at creation; a re-encounter never changes it. `statement` — the proposition at full precision (the identity anchor; shown in the detail card). `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** and **no `description`** (2026-09-08 amendment: with real data, full-sentence canvas labels proved unworkable — ten-line labels, illegible overprint. The label is the name; the precision lives one interaction away in the statement). - **Misconceptions are not a node category.** A myth enters the graph as its true correction ("The 10%-of-brain claim is a myth"); the learner's holding of the wrong idea lives entirely in the event ledger. Wrongness is about the learner, and the learner lives in the ledger. ### Edges (DECIDED 2026-09-02) @@ -226,6 +226,7 @@ The teacher's context window and attention are precious — it has the lesson to | `taught` events (marking things done) | Dropped; the extractor records per-proposition understanding notes (named-word scale) as session-scoped observations instead | | Chat-granular node/edge provenance | Message-granular (chat, message) provenance on nodes and edges — the exact derivation points | | `name` + `statement` on nodes | `statement` (minimal proposition, the rendering label) + `description` (required elaboration) + `aliases` (verbatim phrasings) | +| `statement` + `description` (2026-09-02) | `name` (very short label, the rendering handle) + `statement` (full precision, identity anchor, detail card); `description` dropped — full-sentence canvas labels cluttered the graph (2026-09-08, seeing real extracted data) | | Updates per completed session | Continuous; partial sessions deposit value | | Mermaid DAG as the plan artifact | Structured tools as source of truth; mermaid only for ad-hoc diagrams | | UI deferred, "not important" | UI exists (Next.js, v3.4); the graph view is the app's home page | diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 843be08..4dd1e9d 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -99,6 +99,13 @@ const messagesField = z const propositionSchema = z.object({ id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."), + name: z + .string() + .describe( + "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.", + ), statement: z .string() .describe( @@ -107,11 +114,6 @@ const propositionSchema = z.object({ '"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' + 'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.', ), - description: z - .string() - .describe( - "The elaborated version of the same proposition — more words: scope, precision, what exactly it means. Still true, still world-facing.", - ), aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."), messages: messagesField, evidence, @@ -168,8 +170,12 @@ misconception is NOT a node of its own wrong claim — state the TRUE correction quiz outcomes carry that the learner believed the wrong version. 1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched: - - Each is a self-contained TRUE statement ("statement"), plus its elaboration ("description" — more - words, same proposition: scope, precision, what exactly it means). + - Each has a "name" — a VERY short label, 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. + It becomes the node's label in the graph, so keep it glanceable: names are what one reads on + the graph, statements are what one reads on the node. + - And a "statement" — the self-contained TRUE proposition at full precision (the elaboration lives + in the graph only as this one statement). - 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] tags of every message where the proposition came up — provenance, report it. @@ -254,10 +260,12 @@ FAITHFULNESS — verify every candidate against the transcript: scale or drop. IDENTITY — link-or-create, per proposition: -- You see the current graph (every node: id, statement, description, aliases). For each verified proposition, +- You see the current graph (every node: id, name, statement, aliases). For each verified proposition, decide: does an EXISTING node already state this same proposition? -- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance. -- If no: omit "linkTo" and a new node is created. +- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance; + the node keeps its existing name (labels stay stable across sessions — ignore your name for linked nodes). +- If no: omit "linkTo" and a new node is created, with the extractor's name as its label. Reword only a + clearly unglanceable name (an unwieldy sentence of a name); keep it short. - When unsure, omit — duplicates are recoverable later; wrong merges are not. Also drop propositions not referenced by anything else ONLY if they have no other backing — an unreferenced but @@ -271,7 +279,7 @@ function graphView(nodes: GraphNode[]): string { if (nodes.length === 0) return "(the graph is currently empty — every proposition is a new node)"; const lines = nodes.map( (n) => - `- ${n.id} :: ${n.statement}\n description: ${n.description}` + + `- ${n.id} :: ${n.name}\n statement: ${n.statement}` + (n.aliases.length > 0 ? `\n aliases: ${n.aliases.join(" | ")}` : ""), ); return lines.join("\n"); diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index bb0d8e1..8ee59cf 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -33,13 +33,18 @@ export interface ProvenancePoint { export interface GraphNode { /** Opaque stable id, store-assigned: "n_" + nanoid-like. */ id: string; - /** The minimal proposition — one true, self-contained statement. This is - * BOTH the rendering label AND the identity key: the auditor recognizes - * a re-encounter by judgment (link-or-create), not by string matching. */ + /** A very short label for the node — a compact handle like a glossary + * headword ("Will to power", "Divine command theory"): what the graph + * shows at a glance. Presentational only — not unique, not an identity + * key, never precise: the statement carries the precision. Set at + * creation (first line defines); a link-append never changes it. */ + name: string; + /** The minimal proposition — one true, self-contained statement, at + * full precision. The IDENTITY ANCHOR: the auditor recognizes a + * re-encounter by judgment against this (link-or-create), not by + * string matching. Shown in the detail card; the canvas label is the + * name. */ statement: string; - /** The elaborated version — required. Same proposition, more words: - * scope, precision, the "what exactly does it mean". */ - description: string; /** Verbatim phrasings sessions used for this proposition (the identity * audit trail — how the world said what the node says). */ aliases: string[]; @@ -108,10 +113,11 @@ export interface LedgerEvent { * (nodes/edges/events/mappings/sessions), all append-only. * * nodes.jsonl one line per node — the FIRST line for an id defines - * statement/description/createdAt; later lines for the + * name/statement/createdAt; later lines for the * same id are link-appends (a session's re-encounter): * their statements/aliases join `aliases`, their - * provenance unions. getGraph merges by id. + * provenance unions; the name stays the first line's. + * getGraph merges by id. * edges.jsonl one line per edge ASSERTION; readers merge by * (from,to,type), unioning provenance. * @@ -136,13 +142,16 @@ export interface LedgerEvent { export interface ExtractedProposition { /** Extractor-local id ("p1", …) referenced by edges + attribution. */ 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 + * statement carries the precision. Becomes the node's name on create; + * on link, the existing node keeps its name. */ + name: string; /** The minimal proposition — a true, self-contained, world-facing - * statement. No learner / teacher / session references: misconceptions - * are stated as the true correction, and wrongness lives in the event - * ledger, never in the node. */ + * statement, at full precision. No learner / teacher / session + * references: misconceptions are stated as the true correction, and + * wrongness lives in the event ledger, never in the node. */ statement: string; - /** The elaborated version — required. */ - description: string; /** Verbatim phrasings the session used. */ aliases: string[]; /** The message ids where this proposition came up — provenance. */ diff --git a/src/core/graph.ts b/src/core/graph.ts index 1501078..d3791bd 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -166,10 +166,12 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep // line — the session's phrasings (statement + aliases) join the // node's aliases, the provenance unions. Reads merge by id. graphNodeId = p.linkTo; + // Link-append: phrasings + provenance join at read time (the + // first line's name/statement/createdAt stand). ledgers.appendNode({ id: graphNodeId, + name: p.name, statement: p.statement, - description: p.description, aliases: [...p.aliases], createdAt: now, provenance: points, @@ -177,8 +179,8 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep } else { const node: GraphNode = { id: newGraphNodeId(), + name: p.name, statement: p.statement, - description: p.description, aliases: [...p.aliases], createdAt: now, provenance: points, @@ -329,8 +331,9 @@ export interface DerivedGraph { /** * The whole graph from the ledgers. Node LINES may repeat an id (link- - * appends): the FIRST line defines statement/description/createdAt; later - * lines' statements and aliases join `aliases`, provenance unions. Edge + * appends): the FIRST line defines name/statement/createdAt; later + * lines' statements and aliases join `aliases`, provenance unions — the + * name never changes after creation. Edge * LINES are assertions, merged by (from,to,type) with unioned provenance, * first-seen order. `store events, derive state` — this is the only reader * that matters. diff --git a/src/store/graph.ts b/src/store/graph.ts index 8bc9353..ac86c45 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -83,8 +83,8 @@ const isProvenancePoint = (v: unknown): v is ProvenancePoint => const isGraphNode = (v: unknown): v is GraphNode => isRecord(v) && isString(v.id) && + isString(v.name) && isString(v.statement) && - isString(v.description) && Array.isArray(v.aliases) && v.aliases.every(isString) && isString(v.createdAt) && diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index d275ffc..b533895 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -135,8 +135,8 @@ const FINDINGS: SessionFindings = { propositions: [ { id: "p1", + name: "Derivative", statement: "The derivative measures how fast a function changes", - description: "The derivative of a function at a point is its instantaneous rate of change there.", aliases: [], messages: ["a2"], evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."], @@ -144,8 +144,8 @@ const FINDINGS: SessionFindings = { }, { id: "p2", - statement: "Chain rule intuition", - description: "The chain rule differentiates composed functions by multiplying local rates.", + name: "Chain rule", + statement: "The chain rule differentiates composed functions by multiplying local rates.", aliases: [], messages: ["a2"], evidence: ["LEARNER [u1]: teach me the chain rule"], @@ -267,8 +267,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const existingId = "n_existing"; ledgers.appendNode({ id: existingId, + name: "Derivative", statement: "Derivatives measure rates of change", - description: "Pre-existing node from an earlier session.", aliases: [], createdAt: "2026-08-01T00:00:00.000Z", provenance: [], diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts index 2611de0..6e1c923 100644 --- a/tests/graph-context.test.ts +++ b/tests/graph-context.test.ts @@ -51,8 +51,8 @@ import { PATHS } from "../config.ts"; function node(id: string, statement: string, over: Partial = {}): GraphNode { return { id, + name: statement, statement, - description: `${statement} — elaborated.`, aliases: [], createdAt: "2026-08-22T00:00:00.000Z", provenance: [], @@ -346,16 +346,16 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => { propositions: [ { id: "p1", - statement: "Derivative as a limit", - description: "The derivative is the limit of difference quotients.", + name: "Derivative as a limit", + statement: "The derivative is the limit of difference quotients.", aliases: [], messages: ["u1"], evidence: ["e"], }, { id: "p2", - statement: "Chain rule intuition", - description: "Composed functions differentiate by multiplying local rates.", + name: "Chain rule", + statement: "Composed functions differentiate by multiplying local rates.", aliases: [], messages: ["u1"], evidence: ["e"], @@ -377,8 +377,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => { propositions: [ { id: "q1", + name: "Derivative as a limit", statement: "The derivative as a limit of quotients", // different phrasing - description: "…", aliases: [], messages: ["u1"], evidence: ["e"], @@ -386,8 +386,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => { }, { id: "q2", + name: "Chain rule", statement: "Chain rule, intuitively", - description: "…", aliases: [], messages: ["u1"], evidence: ["e"], diff --git a/tests/graph-view-filters.test.ts b/tests/graph-view-filters.test.ts index aeb7b56..f42e062 100644 --- a/tests/graph-view-filters.test.ts +++ b/tests/graph-view-filters.test.ts @@ -10,8 +10,8 @@ import type { NodeStateClass } from "../src/core/index.ts"; const none: ReadonlySet = new Set(); const node = { - statement: "The money multiplier is the maximum ratio of broad money to base money.", - description: "How much broad money banks create from each unit of base money.", + name: "Money multiplier", + statement: "The maximum ratio of broad money a bank can create from each unit of base money.", }; describe("isNodeVisible (legend/filters predicate)", () => { @@ -33,11 +33,16 @@ describe("isNodeVisible (legend/filters predicate)", () => { assert.equal(isNodeVisible(node, "candidate", none, "MONEY multiplier"), true); }); - it("search matches the description when the statement does not", () => { - assert.equal(isNodeVisible(node, "candidate", none, "maximum ratio"), true); + it("search matches the statement when the name does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "MAXIMUM ratio"), true); assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false); }); + it("search matches the name when the statement does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "money MULTIPLIER"), true); + assert.equal(isNodeVisible(node, "candidate", none, " banking"), false); + }); + it("whitespace-only query is no filter", () => { assert.equal(isNodeVisible(node, "missed", none, " "), true); }); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 567d144..9bf55e0 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -167,8 +167,8 @@ function fixtureAudit(): AuditResult { propositions: [ { id: "p1", + name: "Alpha", statement: "Alpha intuition", - description: "Alpha intuition, elaborated: what alpha means and how it behaves in practice.", aliases: ["alpha feel"], messages: ["a1", "a3"], evidence: ["fixture evidence"], @@ -176,16 +176,16 @@ function fixtureAudit(): AuditResult { }, { id: "p2", + name: "Beta rule", statement: "Beta rule", - description: "The beta rule and when it applies.", aliases: [], messages: ["a4"], evidence: ["fixture evidence"], }, { id: "p3", + name: "Gamma", statement: "Gamma application", - description: "Applying the whole framework to gamma cases.", aliases: [], messages: ["a5"], evidence: ["fixture evidence"], @@ -241,7 +241,7 @@ describe("the merger (mergeFindings)", () => { const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id])); for (const node of graph.nodes) { assert.match(node.id, /^n_/); - assert.ok(node.description.length > 0, "description is required"); + 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"); } @@ -333,8 +333,8 @@ describe("the merger (mergeFindings)", () => { propositions: [ { id: "q1", + name: "Alpha", statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings - description: "The intuitive grasp of alpha.", aliases: ["alpha intuition"], messages: ["a1"], evidence: ["fixture evidence"], @@ -390,8 +390,8 @@ describe("the merger (mergeFindings)", () => { propositions: [ { id: "r1", + name: "Alpha", statement: "Alpha intuition", // IDENTICAL to the existing node's statement - description: "Same words, no verdict.", aliases: [], messages: ["u1"], evidence: ["fixture evidence"], @@ -473,22 +473,24 @@ describe("graph ledger store", () => { it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => { const node = { id: "n_storetest", - statement: "Store test", - description: "Store test, elaborated.", + name: "Store test", + statement: "Store test, elaborated.", aliases: [] as string[], createdAt: "2026-08-22T00:00:00.000Z", provenance: [{ chatId: "chat_c", messageId: "m1" }], }; graphStore.appendNode(node); - // A torn append and a legacy pre-redesign node (old schema) — both skipped. + // A torn append and a pre-name-pivot node (old schema: description, no + // name) — both skipped by the strict guard. fs.appendFileSync( LEDGER_FILES.nodes, - `{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", kind: "truth", name: "Legacy", createdAt: "2026-08-22T00:00:00.000Z" })}\n`, + `{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", description: "Old schema.", aliases: [], createdAt: "2026-08-22T00:00:00.000Z", provenance: [] })}\n`, "utf-8", ); const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy"); assert.equal(nodes.length, 1); - assert.equal(nodes[0]!.statement, "Store test"); + assert.equal(nodes[0]!.name, "Store test"); + assert.equal(nodes[0]!.statement, "Store test, elaborated."); assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]); }); @@ -496,8 +498,8 @@ describe("graph ledger store", () => { const line = (msgId: string) => JSON.stringify({ id: "n_linktest", + name: "Link test", statement: `Phrasing ${msgId}`, - description: "Elaboration.", aliases: [], createdAt: "2026-08-22T00:00:00.000Z", provenance: [{ chatId: "chat_c", messageId: msgId }],