Graph redesign: true propositions, follows/supports, auditor-owned identity (vision §1 minimal implementation)

The minimal implementation of the 2026-09-02 graph vision (docs/vision.md §1):

- contracts/graph.ts: every node is a TRUE proposition (statement is both
  rendering label and identity key; required description; no kind, no name).
  Edges are follows (derivable) / supports (grounds without entailing) with
  message-granular provenance points {chatId, messageId}. Events reduce to
  correct/miss/dont-know plus observation (understanding notes); no taught
  events, no misconception kind.
- extraction.ts: extractor is TREE-BLIND (transcript only, no plan tree) and
  free-form; auditor sees findings + the current graph, owns faithfulness and
  identity (link-or-create verdict per proposition, recorded as linkTo);
  transcriptView tags every line with its message id (provenance anchors).
- core/graph.ts merger: applies verdicts deterministically — linkTo appends
  a link line (phrasings join aliases, provenance unions; reads merge by id),
  absent creates; NO string matching anywhere. Quiz events rebuilt from tool
  parts with attribution-only nodeId resolution; understanding notes become
  observation events (obs:<sourceId>); mapping lines are the verdict audit
  trail and make re-merges idempotent.
- deriveNodeStates: transitional — latest quiz event wins (known/missed/
  dont-know, else candidate); taught unreachable until the state-model
  ideation lands.
- graph-context.ts + 50-graph-context.md: follows chains, hotspot grouping,
  state vocabulary updated for the new event kinds.
- graph-view.tsx: node label is the statement; detail shows statement +
  description.
- tests: merger verdicts (link, no-string-match, cross-chat assertions),
  tree-blind pipeline end-to-end, follows vocabulary in context tests.
  276/276 green.
This commit is contained in:
amosblomqvist
2026-09-08 22:19:37 +03:00
parent e322d775e4
commit b07c907ffd
13 changed files with 945 additions and 813 deletions
+28 -35
View File
@@ -1,20 +1,26 @@
/**
* GraphStore — the fs JSONL adapter: five append-only ledgers at
* config.LEDGER_FILES (contracts.ts "v1 emission").
* nodes.jsonl GraphNode — one line per graph node
* nodes.jsonl GraphNode — one line per node write: the FIRST
* line for an id defines it; a later
* line with a REPEATED id is a
* link-append (session phrasings join
* aliases, provenance unions); reads
* merge by id (core/graph.ts getGraph)
* edges.jsonl GraphEdge — one line per edge ASSERTION (a second
* session asserting the same (from,to,type)
* appends another line; reads merge)
* events.jsonl LedgerEvent — quiz outcomes + taught events
* events.jsonl LedgerEvent — quiz outcomes + understanding observations
* mappings.jsonl NodeMapping — the session→graph identity map:
* (chatId, sourceId) → graphNodeId
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt)
*
* Implements core/ports.ts GraphStore. data/graph/* is exclusively this
* adapter's. Reads are strict against the contracts.ts shapes: corrupt lines
* are skipped, never guessed at — the ledgers are the source of truth and
* everything else is derived (core/graph.ts, which takes this port as a
* parameter). Same idioms as src/store/chats.ts: sync fs, no framework.
* adapter's. Reads are strict against the contracts.ts shapes (the 2026-09-02
* graph redesign): corrupt lines are skipped, never guessed at — the ledgers
* are the source of truth and everything else is derived (core/graph.ts,
* which takes this port as a parameter). Same idioms as src/store/chats.ts:
* sync fs, no framework.
*/
import * as fs from "node:fs";
@@ -26,6 +32,7 @@ import type {
LedgerEvent,
LedgerEventKind,
NodeMapping,
ProvenancePoint,
SessionCheckpoint,
} from "../core/contracts.ts";
import type { GraphStore } from "../core/ports.ts";
@@ -69,26 +76,22 @@ function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
return readRawLines(file).filter(isValid);
}
/** Pre-name ledger line (the first extraction runs) — read tolerantly. */
type StoredGraphNode = Omit<GraphNode, "name"> & { name?: string };
/** Provenance points are (chatId, messageId) pairs — message-granular. */
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
isRecord(v) && isString(v.chatId) && isString(v.messageId);
const isStoredGraphNode = (v: unknown): v is StoredGraphNode =>
const isGraphNode = (v: unknown): v is GraphNode =>
isRecord(v) &&
isString(v.id) &&
isString(v.statement) &&
isString(v.description) &&
Array.isArray(v.aliases) &&
(v.kind === "truth" || v.kind === "misconception") &&
v.aliases.every(isString) &&
isString(v.createdAt) &&
(v.name === undefined || isString(v.name));
Array.isArray(v.provenance) &&
v.provenance.every(isProvenancePoint);
/** Derived header for legacy node lines written before `name` existed:
* the first clause of the statement, capped — good enough to render. */
function nameFromStatement(statement: string): string {
const clause = statement.split(/[.;:—]/)[0]!.trim();
return clause.length > 60 ? `${clause.slice(0, 57)}…` : clause || statement;
}
const EDGE_TYPES = ["requires", "supports"] as const;
const EDGE_TYPES = ["follows", "supports"] as const;
const isGraphEdge = (v: unknown): v is GraphEdge =>
isRecord(v) &&
@@ -96,17 +99,9 @@ const isGraphEdge = (v: unknown): v is GraphEdge =>
isString(v.to) &&
(EDGE_TYPES as readonly string[]).includes(v.type as string) &&
Array.isArray(v.provenance) &&
v.provenance.every(isString);
v.provenance.every(isProvenancePoint);
const EVENT_KINDS: readonly LedgerEventKind[] = [
"probe-correct",
"probe-miss",
"dont-know",
"misconception-signaled",
"taught",
"check-correct",
"check-incorrect",
];
const EVENT_KINDS: readonly LedgerEventKind[] = ["correct", "miss", "dont-know", "observation"];
const isLedgerEvent = (v: unknown): v is LedgerEvent =>
isRecord(v) &&
@@ -127,13 +122,11 @@ export const graphStore: GraphStore = {
appendNode(node: GraphNode): void {
appendLine(LEDGER_FILES.nodes, node);
},
/** Raw node lines in append order (ids are unique — no merging needed).
* Legacy lines without a `name` get one derived from the statement. */
/** Raw node lines in append order. A node id may appear on more than
* one line (a later session's LINK-append: phrasings + provenance); the
* derivation (core/graph.ts getGraph) merges lines by id. */
readNodes(): GraphNode[] {
return readValid(LEDGER_FILES.nodes, isStoredGraphNode).map((n) => ({
...n,
name: n.name ?? nameFromStatement(n.statement),
}));
return readValid(LEDGER_FILES.nodes, isGraphNode);
},
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */