Files
learnharness/src/store/graph.ts
T
amosblomqvist 73027f49bf Cost channel cull: the cluster-naming fold dies — two stages, two channels
Off UI's d651f93 flag (displayCostTotals already isolates the display to
extraction + audit; the naming stage row and naming runs were filtered
out client-side). This culls the server side of the same ruling.

THE STANDING RULING, REVERSED BY ITS OWN PREMISE: NamingRunUsage /
readNamingRuns / appendNamingUsage were kept at the wave-7 namer kill
ONLY because cost-summary's HISTORICAL naming stage read them. With the
fold culled, that premise dies — so the whole ledger read path goes too,
following the wave-7 embeddings precedent chain: consumer-less ledger
seams are deleted, and the on-disk naming-runs.jsonl stays INERT (real
trees keep their historical spend bytes; they are simply not displayed
anywhere — the same treatment as historical stages.embedding spend).

- core/cost-summary.ts: two channels in (chat + notes front doors), two
  stages out; RecentRun.kind collapses to "extraction" (labels field
  gone); CostSummaryInput.naming gone; totalDollars = extraction + audit.
- ports/store/contracts/config: appendNamingUsage + readNamingRuns ports,
  store impls + guards, the NamingRunUsage interface, and
  LEDGER_FILES.namingRuns die. graphRevision no longer counts the file
  (a smaller revision value is harmless — reads never compare).
- components/graph-view.tsx (mechanical, flagged): the defensive
  .filter(run.kind !== "naming") guards a kind that no longer exists.
- Tests: cost-summary's both-channels fold pins; the naming fixtures and
  the appendNamingUsage store pin die with the seams.
2026-09-24 19:33:35 +03:00

331 lines
12 KiB
TypeScript

/**
* GraphStore — the fs JSONL adapter: five append-only ledgers at
* config.LEDGER_FILES (contracts.ts "v1 emission").
* 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 + understanding observations
* mappings.jsonl NodeMapping — the session→graph identity map:
* (chatId, sourceId) → graphNodeId
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt)
*
* Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping
* (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write
* like a chat file. The format-agnostic front door (2026-09-13).
*
* Implements core/ports.ts GraphStore. data/graph/* is exclusively this
* 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";
import * as path from "node:path";
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
import type {
GraphEdge,
GraphNode,
ExtractionRunUsage,
LedgerEvent,
LedgerEventKind,
NotesSource,
NodeMapping,
Position,
ProvenancePoint,
CoverageEntry,
SessionCheckpoint,
} from "../core/contracts.ts";
import type { GraphStore } from "../core/ports.ts";
// ─── JSONL primitives ────────────────────────────────────────────────────────
function appendLine<T>(file: string, record: T): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8");
}
function readRawLines(file: string): unknown[] {
let raw: string;
try {
raw = fs.readFileSync(file, "utf-8");
} catch {
return []; // no ledger yet — nothing has ever been emitted
}
const out: unknown[] = [];
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
try {
out.push(JSON.parse(trimmed));
} catch {
// Corrupt line: skip rather than fail the whole read — a torn
// append must never take the home graph down with it.
}
}
return out;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
const isString = (v: unknown): v is string => typeof v === "string";
/** Strict shape guards — anything not matching a W4 ledger shape is skipped. */
function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
return readRawLines(file).filter(isValid);
}
/** Provenance points are (chatId, messageId) pairs — message-granular. */
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
isRecord(v) && isString(v.chatId) && isString(v.messageId);
const isGraphNode = (v: unknown): v is GraphNode =>
isRecord(v) &&
isString(v.id) &&
isString(v.name) &&
isString(v.statement) &&
Array.isArray(v.aliases) &&
v.aliases.every(isString) &&
isString(v.createdAt) &&
Array.isArray(v.provenance) &&
v.provenance.every(isProvenancePoint);
const EDGE_TYPES = ["follows", "supports"] as const;
const isGraphEdge = (v: unknown): v is GraphEdge =>
isRecord(v) &&
isString(v.from) &&
isString(v.to) &&
(EDGE_TYPES as readonly string[]).includes(v.type as string) &&
Array.isArray(v.provenance) &&
v.provenance.every(isProvenancePoint);
const EVENT_KINDS: readonly LedgerEventKind[] = ["correct", "miss", "dont-know", "observation"];
const isLedgerEvent = (v: unknown): v is LedgerEvent =>
isRecord(v) &&
(v.nodeId === null || isString(v.nodeId)) &&
(EVENT_KINDS as readonly string[]).includes(v.kind as string) &&
isString(v.chatId) &&
isString(v.at);
const isNodeMapping = (v: unknown): v is NodeMapping =>
isRecord(v) && isString(v.chatId) && isString(v.sourceId) && isString(v.graphNodeId);
const isCheckpoint = (v: unknown): v is SessionCheckpoint =>
isRecord(v) && isString(v.chatId) && isString(v.foldedAt);
const isCoverageEntry = (v: unknown): v is CoverageEntry =>
isRecord(v) && isString(v.chatId) && isString(v.fp) && typeof v.tokens === "number" && isString(v.foldedAt);
// ─── the port implementation ────────────────────────────────────────────────
export const graphStore: GraphStore = {
appendNode(node: GraphNode): void {
appendLine(LEDGER_FILES.nodes, node);
},
/** 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, isGraphNode);
},
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */
appendEdge(assertion: GraphEdge): void {
appendLine(LEDGER_FILES.edges, assertion);
},
readEdges(): GraphEdge[] {
return readValid(LEDGER_FILES.edges, isGraphEdge);
},
appendEvent(event: LedgerEvent): void {
appendLine(LEDGER_FILES.events, event);
},
readEvents(): LedgerEvent[] {
return readValid(LEDGER_FILES.events, isLedgerEvent);
},
appendMapping(mapping: NodeMapping): void {
appendLine(LEDGER_FILES.mappings, mapping);
},
readMappings(): NodeMapping[] {
return readValid(LEDGER_FILES.mappings, isNodeMapping);
},
appendCheckpoint(checkpoint: SessionCheckpoint): void {
appendLine(LEDGER_FILES.sessions, checkpoint);
},
readCheckpoints(): SessionCheckpoint[] {
return readValid(LEDGER_FILES.sessions, isCheckpoint);
},
appendCoverage(entry: CoverageEntry): void {
appendLine(LEDGER_FILES.coverage, entry);
},
readCoverage(): CoverageEntry[] {
return readValid(LEDGER_FILES.coverage, isCoverageEntry);
},
// ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ──
/** Upsert: keeps the existing record's usage log when re-importing. */
upsertSource(source: NotesSource): void {
const all = readSourceMap();
const prev = all[source.id];
all[source.id] = prev ? { ...source, extractions: prev.extractions } : source;
writeSourceMap(all);
},
readSources(): NotesSource[] {
return Object.values(readSourceMap());
},
/** Idempotent per startedAt — same discipline as chats.appendExtractionUsage. */
appendSourceUsage(id: string, entry: ExtractionRunUsage): void {
const all = readSourceMap();
const source = all[id];
if (!source) return; // no such source — nothing to bill against
source.extractions = source.extractions?.some((r) => r.startedAt === entry.startedAt)
? source.extractions
: [...(source.extractions ?? []), entry];
writeSourceMap(all);
},
// ── the layout ledger (config.POSITIONS_FILE — a JSON map) ──
readPositions(): Record<string, Position> {
return readPositionMap();
},
writePositions(positions: Record<string, Position>): void {
writePositionMap(positions);
},
/** THE DESTRUCTIVE SEAM (wave 6 session deletion): atomic full-content
* replacement per provided ledger — write a uniquely-named temp file
* beside the ledger, then rename over it (POSIX-atomic: a crash mid-
* write leaves the original ledger and an orphan .tmp-*, never a torn
* ledger). Empty arrays write an empty file — a purged ledger reads
* as "nothing yet", same as never-emitted. */
rewriteLedgers(next: {
nodes?: GraphNode[];
edges?: GraphEdge[];
events?: LedgerEvent[];
mappings?: NodeMapping[];
coverage?: CoverageEntry[];
checkpoints?: SessionCheckpoint[];
}): void {
const files = {
nodes: LEDGER_FILES.nodes,
edges: LEDGER_FILES.edges,
events: LEDGER_FILES.events,
mappings: LEDGER_FILES.mappings,
coverage: LEDGER_FILES.coverage,
checkpoints: LEDGER_FILES.sessions,
} as const;
for (const [key, records] of Object.entries(next) as [keyof typeof files, unknown[]][]) {
const file = files[key];
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp-${crypto.randomUUID().slice(0, 8)}`;
try {
// JSONL: one record per line, trailing newline — a reader
// must see exactly what appendLine would have written.
const body = records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : "");
fs.writeFileSync(tmp, body, "utf-8");
fs.renameSync(tmp, file);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* the rename either already happened or the tmp never existed */
}
throw err;
}
}
},
};
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT
* append-only: the settle is a full-graph computation, so it replaces the
* whole map. The updatedAt stamp exists for future live-physics conflict
* handling (a browser commit must be able to detect "the graph changed
* underneath me"); reads just return the map. */
function readPositionMap(): Record<string, Position> {
let raw: string;
try {
raw = fs.readFileSync(POSITIONS_FILE, "utf-8");
} catch {
return {}; // no ledger yet — nothing has ever settled
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed) || !isRecord(parsed.positions)) return {};
const out: Record<string, Position> = {};
for (const [id, value] of Object.entries(parsed.positions)) {
if (isPosition(value)) out[id] = { x: value.x, y: value.y };
}
return out;
} catch {
return {};
}
}
function writePositionMap(positions: Record<string, Position>): void {
fs.mkdirSync(path.dirname(POSITIONS_FILE), { recursive: true });
// Tmp + rename — the same crash-safety rewriteLedgers uses two blocks
// up (a9339b3 UI flag): a torn write must never replace the ledger
// with a half-written file the reader parses as empty.
const tmp = `${POSITIONS_FILE}.tmp-${crypto.randomUUID().slice(0, 8)}`;
try {
fs.writeFileSync(tmp, JSON.stringify({ positions, updatedAt: new Date().toISOString() }, null, "\t"), "utf-8");
fs.renameSync(tmp, POSITIONS_FILE);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* the rename either already happened or the tmp never existed */
}
throw err;
}
}
const isPosition = (v: unknown): v is Position =>
isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y);
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
* the usage log lives inside the record (like a chat's JSON file). */
function readSourceMap(): Record<string, NotesSource> {
let raw: string;
try {
raw = fs.readFileSync(SOURCES_FILE, "utf-8");
} catch {
return {};
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const out: Record<string, NotesSource> = {};
for (const [id, value] of Object.entries(parsed)) {
if (isNotesSource(value)) out[id] = value;
}
return out;
} catch {
return {};
}
}
function writeSourceMap(map: Record<string, NotesSource>): void {
fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true });
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
}
const isNotesSource = (v: unknown): v is NotesSource =>
isRecord(v) && isString(v.id) && isString(v.filePath) && isString(v.importedAt);