diff --git a/README.md b/README.md index 969a3f9..2cac04d 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,6 @@ npm test # all tests (node:test via tsx; deterministic, no LLM) npm run typecheck # tsc --noEmit npx tsx scripts/bench-graph.ts # force-layout benchmark (synthetic graphs) - -# ── legacy v0 pipeline (dormant, see "Legacy scaffold" below) ────────── -npm run ingest -- # normalize a pi session JSONL -npm run graph # ledger stats ``` ## Environment (`.env.local`) @@ -148,10 +144,6 @@ data/ chats/ · graph/ · jobs/ — runtime state, git **The live graph.** Every completed turn folds the chat into append-only JSONL ledgers (`emitChat`: tree nodes/edges, quiz outcome events, taught events — idempotent, checkpointed, so partial sessions still deposit value). The home view derives node states from the ledgers and lays them out with forceAtlas2 (run-then-freeze — no idle simulation). At session start the derived graph is rendered into a compact markdown block appended to the teacher's instructions (`buildGraphContext`) — whole graph while small, hotspots + 1-hop neighborhood past 150 nodes. -## Legacy scaffold (dormant) - -The pre-pivot v0 pipeline — `src/ledger.ts`, `src/cli.ts` (`npm run ingest` / `graph`), `src/extract/` (normalize ✅, extract/merge stubs), `src/schema.ts`, `src/derive.ts`, `src/queries/` — is kept compiling as reference for a future **retroactive bootstrap** over the old pi session JSONLs (`data/sessions/`, bootstrap input only). It writes to `data/legacy-graph/` and shares nothing with the live store (`data/graph/`, owned by `src/store/graph.ts` per the `contracts.ts` shapes). See `docs/architecture.md` for its design. - ## Testing All deterministic, no LLM (models are injected as `MockLanguageModelV4` where an agent loop is under test): diff --git a/config.ts b/config.ts index f26882c..4e3d580 100644 --- a/config.ts +++ b/config.ts @@ -11,8 +11,6 @@ export const PATHS = { data: path.join(ROOT, "data"), /** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */ chats: path.join(ROOT, "data", "chats"), - /** Old pi session JSONLs — retroactive bootstrap input only. */ - sessions: path.join(ROOT, "data", "sessions"), graph: path.join(ROOT, "data", "graph"), } as const; @@ -27,17 +25,3 @@ export const LEDGER_FILES = { mappings: path.join(PATHS.graph, "mappings.jsonl"), sessions: path.join(PATHS.graph, "sessions.jsonl"), } as const; - -/** - * The legacy V0 scaffold (src/ledger.ts, src/cli.ts, src/extract/) writes - * HERE instead — dormant pre-pivot reference code with its own line shapes - * (src/schema.ts). Kept compiling for the future retroactive bootstrap, - * which will target the contracts.ts shapes, not these. - */ -export const LEGACY_LEDGER_FILES = { - nodes: path.join(PATHS.data, "legacy-graph", "nodes.jsonl"), - edges: path.join(PATHS.data, "legacy-graph", "edges.jsonl"), - events: path.join(PATHS.data, "legacy-graph", "events.jsonl"), - mappings: path.join(PATHS.data, "legacy-graph", "mappings.jsonl"), - sessions: path.join(PATHS.data, "legacy-graph", "sessions.jsonl"), -} as const; diff --git a/package.json b/package.json index cbe1eb4..f2fa53d 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,6 @@ "dev": "next dev", "build": "next build", "start": "next start", - "ingest": "tsx src/cli.ts ingest", - "graph": "tsx src/cli.ts graph", - "what-next": "tsx src/cli.ts what-next", "test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx", "typecheck": "tsc --noEmit" }, diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 69745b1..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -/** - * learning-harness CLI - * - * ingest normalize a session JSONL and print the canonical document - * (Stage 1 only — extractor/merger not yet implemented) - * graph print graph/ledger stats - * what-next (not yet implemented) - * - * The web app (Next.js + AI SDK): npm run dev - */ - -import * as fs from "node:fs"; -import { normalizeSession } from "./extract/normalize.ts"; -import { ledgers } from "./ledger.ts"; -import { deriveAll } from "./derive.ts"; -import { PATHS } from "../config.ts"; - -async function main() { - const [command, ...rest] = process.argv.slice(2); - - switch (command) { - case "ingest": { - const file = rest[0]; - if (!file) { - console.error("Usage: ingest "); - process.exit(1); - } - const doc = normalizeSession(file); - ledgers.appendSession(doc.session); - console.log(`Session: ${doc.session.id}`); - console.log(`Topic: ${doc.session.topic}`); - console.log(`Entries: ${doc.entries.length}`); - const quizzes = doc.entries.filter((e) => e.kind === "quiz"); - console.log(`Quizzes: ${quizzes.length}`); - // Full canonical document for inspection / later pipeline stages. - fs.mkdirSync(PATHS.graph, { recursive: true }); - const out = `${PATHS.graph}/canonical-${doc.session.id.slice(0, 8)}.json`; - fs.writeFileSync(out, JSON.stringify(doc, null, "\t"), "utf-8"); - console.log(`Canonical document written: ${out}`); - break; - } - case "graph": { - const nodes = ledgers.nodes(); - const edges = ledgers.edges(); - const events = ledgers.events(); - const sessions = ledgers.sessions(); - const derived = deriveAll(); - console.log(`Sessions: ${sessions.length}`); - console.log(`Nodes: ${nodes.length} (known: ${derived.filter((d) => d.known).length})`); - console.log(`Edges: ${edges.length}`); - console.log(`Events: ${events.length}`); - break; - } - case "what-next": { - console.error("Not implemented yet — needs extractor + merger first."); - process.exit(1); - } - default: { - console.log("Commands: ingest | graph | what-next"); - process.exit(command ? 1 : 0); - } - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/derive.ts b/src/derive.ts deleted file mode 100644 index 1d3ece4..0000000 --- a/src/derive.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Derived state — pure code, no LLM. - * known (evidence) / understood (structure, aggregation semantics PARKED). - * NOT YET IMPLEMENTED beyond the known-set. - */ - -import { ledgers } from "./ledger.ts"; -import { KNOWING_ORDER, type DerivedNodeState, type KnowingLevel } from "./schema.ts"; - -/** Rubric → scalar. Deterministic code, never an LLM. */ -export function knowingScore(level: KnowingLevel): number { - return KNOWING_ORDER.indexOf(level) / (KNOWING_ORDER.length - 1); -} - -/** Known set: nodes with direct evidence (taught, probe-correct, check-correct...). */ -export function knownNodeIds(): Set { - const evidenced = new Set(); - for (const event of ledgers.events()) { - if (event.kind === "taught" || event.kind === "check-correct" || event.kind === "probe-correct" || event.kind === "recall-at-delay") { - evidenced.add(event.node); - } - } - return evidenced; -} - -/** Full derived state. Understanding aggregation (weakest-link vs mean vs two-level) - * is PARKED pending real extracted data — see docs/architecture.md §7. */ -export function deriveAll(): DerivedNodeState[] { - const known = knownNodeIds(); - return ledgers.nodes().map((node) => ({ - node: node.id, - known: known.has(node.id), - knowing: 0, - understood: 0, - floating: false, - })); -} diff --git a/src/extract/extract.ts b/src/extract/extract.ts deleted file mode 100644 index 4479b40..0000000 --- a/src/extract/extract.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Stage 2 — Extract (one LLM pass over the canonical session document). - * NOT YET IMPLEMENTED. Interface specified so the shape is fixed: - * - * Input: CanonicalSessionDocument - * Output: ExtractedSession (see schema.ts) — nodes, edges, attributed events, - * per-node knowing on the anchored ordinal rubric. - * - * Constraints: - * - One pass for nodes+edges+events+knowing (they are entangled). - * - Zero inference about knowledge beyond the evidence: it extracts facts, - * edges, and evidence — nothing the session doesn't show. - * - Knowing is mapped onto the anchored rubric (KnowingLevel), never a scalar. - * - Edges come primarily from the plan's mermaid DAG (already asserted by the - * teacher); only prose-articulated Connect-step edges need judgment. - * - * Followed by an audit pass (extract/audit.ts): a second LLM call verifying the - * extraction JSON against the session for hallucination/misattribution. - */ - -import type { CanonicalSessionDocument } from "./normalize.ts"; -import type { ExtractedSession } from "../schema.ts"; - -export async function extractSession(doc: CanonicalSessionDocument): Promise { - throw new Error("Not implemented — Stage 2 (extractor LLM pass) is the next build step."); -} diff --git a/src/extract/merge.ts b/src/extract/merge.ts deleted file mode 100644 index 3175af6..0000000 --- a/src/extract/merge.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Stage 3 — Merge (deterministic code + LLM adjudication for node identity). - * NOT YET IMPLEMENTED. Interface specified: - * - * Input: ExtractedSession (Stage 2 output) + canonical graph - * 1. Embed canonical node statements (cached) → kNN candidates per incoming node. - * 2. ONE LLM adjudication call per session: each pair → - * same → alias + attach events to canonical node - * related → new node + proposed cross-session edge (the quiet superpower) - * unrelated → new node, no edge - * 3. Apply deterministically; log every decision as a SessionMapping - * (reversible by design — wrong merges must be one-line fixes). - * - * Policy: fully automatic, conservative — prefer duplicates over wrong merges. - * No domain filter on candidates (cross-domain merging is the point). - */ - -import type { ExtractedSession } from "../schema.ts"; - -export async function mergeSession(extraction: ExtractedSession): Promise { - throw new Error("Not implemented — Stage 3 (merger) comes after the extractor."); -} diff --git a/src/extract/normalize.ts b/src/extract/normalize.ts deleted file mode 100644 index decd4d5..0000000 --- a/src/extract/normalize.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Stage 1 of the extraction pipeline — DETERMINISTIC, no LLM. - * - * pi session JSONL → canonical session document. - * - * The quiz tool writes full structured details into the session JSONL - * (question, options, correct/dontKnow, note, explanation), so quiz events are - * read as gold-standard structured data, not parsed from prose. - */ - -import * as fs from "node:fs"; -import type { - EventKind, - Provenance, - SessionRecord, -} from "../schema.ts"; - -// ─── pi session JSONL entry types (subset we consume) ─────────────────────── - -interface PiEntryBase { - type: string; - id: string; - parentId: string | null; - timestamp: string; -} - -interface PiSessionHeader { - type: "session"; - version: number; - id: string; - timestamp: string; - cwd: string; -} - -interface PiMessageEntry extends PiEntryBase { - type: "message"; - message: { - role: "user" | "assistant" | "toolResult" | string; - content?: unknown; - toolCallId?: string; - toolName?: string; - details?: unknown; - timestamp?: number; - }; -} - -// ─── Canonical session document ────────────────────────────────────────────── - -export interface CanonicalQuizEvent { - kind: "quiz"; - entryId: string; - toolCallEntryId: string; // the assistant entry containing the toolCall - question: string; - context?: string; - options: Array<{ index: number; label: string }>; - answers: Array<{ index: number; label: string }>; - correctIndices: number[]; - correct: boolean; - dontKnow: boolean; - note?: string; - explanation?: string; -} - -export interface CanonicalAskEvent { - kind: "ask"; - entryId: string; - toolCallEntryId: string; - question: string; - answers: Array<{ label: string; value: string }>; -} - -export interface CanonicalTextEvent { - kind: "user" | "assistant"; - entryId: string; - text: string; -} - -export type CanonicalEntry = CanonicalQuizEvent | CanonicalAskEvent | CanonicalTextEvent; - -export interface CanonicalSessionDocument { - session: SessionRecord; - entries: CanonicalEntry[]; -} - -// ─── Parsing ───────────────────────────────────────────────────────────────── - -function textFromContent(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter((b): b is { type: "text"; text: string } => typeof b === "object" && b !== null && (b as { type: string }).type === "text") - .map((b) => b.text) - .join("\n"); -} - -interface QuizDetails { - status: string; - question: string; - mode: string; - answers: Array<{ index: number; label: string }>; - correctIndices: number[]; - options?: Array<{ index: number; label: string }>; - correct?: boolean; - dontKnow?: boolean; - note?: string; - explanation?: string; -} - -interface AskDetails { - status: string; - question: string; - mode: string; - answers?: Array<{ label: string; value: string }>; -} - -export function normalizeSession(file: string): CanonicalSessionDocument { - const lines = fs.readFileSync(file, "utf-8").split("\n").filter((l) => l.trim().length > 0); - const parsed = lines.map((l) => JSON.parse(l)); - - const header = parsed[0] as PiSessionHeader; - if (header?.type !== "session") throw new Error(`${file}: not a pi session file (missing header)`); - - const entries: CanonicalEntry[] = []; - const quizArgsByCallId = new Map(); - - for (const raw of parsed.slice(1)) { - const entry = raw as PiMessageEntry; - if (entry.type !== "message") continue; - const msg = entry.message; - - if (msg.role === "user") { - const text = textFromContent(msg.content).trim(); - if (text) entries.push({ kind: "user", entryId: entry.id, text }); - } - - if (msg.role === "assistant" && Array.isArray(msg.content)) { - // Collect assistant text (lesson prose — node articulations live here). - const text = textFromContent(msg.content).trim(); - if (text) entries.push({ kind: "assistant", entryId: entry.id, text }); - // Remember quiz/ask toolCall args (the question) keyed by call id. - for (const block of msg.content) { - if (typeof block === "object" && block !== null && (block as { type: string }).type === "toolCall") { - const call = block as { type: "toolCall"; id: string; name: string; arguments: Record }; - if (call.name === "quiz" || call.name === "ask_user_question") { - quizArgsByCallId.set(call.id, { - entryId: entry.id, - question: String(call.arguments.question ?? ""), - context: call.arguments.details ? String(call.arguments.details) : undefined, - }); - } - } - } - } - - if (msg.role === "toolResult" && (msg.toolName === "quiz" || msg.toolName === "ask_user_question")) { - const args = quizArgsByCallId.get(msg.toolCallId ?? ""); - if (msg.toolName === "quiz") { - const d = msg.details as QuizDetails | undefined; - if (d && d.status === "answered") { - entries.push({ - kind: "quiz", - entryId: entry.id, - toolCallEntryId: args?.entryId ?? entry.id, - question: d.question ?? args?.question ?? "", - context: args?.context, - options: d.options ?? [], - answers: d.answers ?? [], - correctIndices: d.correctIndices ?? [], - correct: d.correct ?? false, - dontKnow: d.dontKnow ?? false, - note: d.note, - explanation: d.explanation, - }); - } - } else { - const d = msg.details as AskDetails | undefined; - if (d && d.status === "answered") { - entries.push({ - kind: "ask", - entryId: entry.id, - toolCallEntryId: args?.entryId ?? entry.id, - question: d.question ?? args?.question ?? "", - answers: d.answers ?? [], - }); - } - } - } - } - - const firstUser = entries.find((e): e is CanonicalTextEvent => e.kind === "user"); - - return { - session: { - id: header.id, - startedAt: header.timestamp, - topic: firstUser ? firstUser.text.slice(0, 120) : "(no user message)", - file, - }, - entries, - }; -} - -/** Coarse pre-plan / post-plan classification for quiz events (refined by the extractor LLM). */ -export function quizPhase(index: number, planIndex: number): "probe" | "check" { - return index < planIndex ? "probe" : "check"; -} - -/** Provisional event kind from a quiz outcome + phase (the extractor refines/attribution). */ -export function provisionalEventKind(quiz: CanonicalQuizEvent, phase: "probe" | "check"): EventKind { - if (quiz.dontKnow) return "dontKnow"; - if (phase === "probe") return quiz.correct ? "probe-correct" : "probe-miss"; - return quiz.correct ? "check-correct" : "check-incorrect"; -} - -export function provenanceOf(sessionId: string, entryId: string): Provenance { - return { session: sessionId, entryId }; -} diff --git a/src/ledger.ts b/src/ledger.ts deleted file mode 100644 index 7ca0106..0000000 --- a/src/ledger.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Append-only JSONL ledgers. Same philosophy as the design: the ledgers are - * the source of truth; everything else is derived and recomputable. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { randomBytes } from "node:crypto"; -import { LEGACY_LEDGER_FILES as LEDGER_FILES } from "../config.ts"; - -export function newId(prefix: string): string { - return `${prefix}_${randomBytes(5).toString("hex")}`; -} - -export function append(file: string, record: T): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.appendFileSync(file, JSON.stringify(record) + "\n", "utf-8"); -} - -export function readAll(file: string): T[] { - if (!fs.existsSync(file)) return []; - const out: T[] = []; - for (const line of fs.readFileSync(file, "utf-8").split("\n")) { - const trimmed = line.trim(); - if (trimmed.length > 0) out.push(JSON.parse(trimmed) as T); - } - return out; -} - -// Typed helpers ─────────────────────────────────────────────────────────────── - -import type { - GraphEdge, - GraphNode, - KnowledgeEvent, - SessionMapping, - SessionRecord, -} from "./schema.ts"; - -export const ledgers = { - appendSession(record: SessionRecord) { - // Session records are unique per id — skip if already ingested. - if (readAll(LEDGER_FILES.sessions).some((s) => s.id === record.id)) return false; - append(LEDGER_FILES.sessions, record); - return true; - }, - sessions: () => readAll(LEDGER_FILES.sessions), - - appendNode(node: GraphNode) { - append(LEDGER_FILES.nodes, node); - }, - nodes: () => readAll(LEDGER_FILES.nodes), - - appendEdge(edge: GraphEdge) { - append(LEDGER_FILES.edges, edge); - }, - edges: () => readAll(LEDGER_FILES.edges), - - appendEvent(event: KnowledgeEvent) { - append(LEDGER_FILES.events, event); - }, - events: () => readAll(LEDGER_FILES.events), - - appendMapping(mapping: SessionMapping) { - append(LEDGER_FILES.mappings, mapping); - }, - mappings: () => readAll(LEDGER_FILES.mappings), -}; diff --git a/src/queries/what-next.ts b/src/queries/what-next.ts deleted file mode 100644 index 0f9ea97..0000000 --- a/src/queries/what-next.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * What-next queries: territory / repairs / conversions (+ unlock-value over - * the expanded frontier). NOT YET IMPLEMENTED — needs the graph populated first. - * - * These run as pure traversals over the derived graph: - * territory — frontier candidates ranked by work-to-ready (missing - * requires-subtree mass), NOT fewest edges. - * repairs — weak/shaky/misconceived nodes INSIDE the held region, - * ranked by downstream load. - * conversions — missing requires-roots that would flip known-but-floating - * nodes into understood. - * unlock-value — over the lazily expanded frontier, discounted by depth in - * generated territory; presented as ranked heuristic, never a number. - */ - -export async function whatNext(): Promise { - throw new Error("Not implemented — needs a populated graph (extractor + merger first)."); -} diff --git a/src/schema.ts b/src/schema.ts deleted file mode 100644 index e011258..0000000 --- a/src/schema.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Knowledge graph data model. - * - * Design invariants (see docs/architecture.md and the design session): - * 1. Store the LEDGERS, derive the GRAPH. Events/edges/mappings are append-only - * evidence; node states and strengths are always recomputable from them. - * 2. Nodes are propositions at ~one-reasoning-step grain. - * 3. Edges carry NO scalars — only type (requires/supports) and provenance. - * All mass lives in nodes. "Load-bearing" is derived, never stored. - * 4. known ≠ understood. Known = evidence property (from the event ledger). - * Understood = derived structural property (known + requires-subtree held). - * 5. Candidates (frontier-generated nodes) are a separate state class; - * promotion only happens when a session touches them or the learner - * picks them as a goal — never automatically. - * 6. Every event carries session + entryId → jump-to-point provenance. - */ - -// ─── Sessions ──────────────────────────────────────────────────────────────── - -export interface SessionRecord { - /** pi session UUID (from the session JSONL header). */ - id: string; - /** ISO timestamp from the header. */ - startedAt: string; - /** Human topic label (first user message, or /name). */ - topic: string; - /** Absolute path of the session JSONL. */ - file: string; -} - -// ─── Nodes ─────────────────────────────────────────────────────────────────── - -export type NodeKind = "truth" | "misconception"; -export type NodeState = "known" | "not-held" | "candidate"; - -export interface GraphNode { - /** Opaque stable id, e.g. "n_a1b2c3". */ - id: string; - /** The proposition, one reasoning step of grain. */ - statement: string; - kind: NodeKind; - /** Domain tag — metadata for visualization, NEVER a merge barrier. */ - domain: string; - /** candidate = generated by frontier expansion, not yet evidenced. */ - state: NodeState; - createdAt: number; -} - -// ─── Edges ─────────────────────────────────────────────────────────────────── - -export type EdgeType = "requires" | "supports"; - -export interface GraphEdge { - id: string; - /** The dependency (e.g. "gradient" for gradient descent). */ - from: string; - /** The dependent (e.g. "gradient descent"). */ - to: string; - /** - * requires — hard: understanding the target requires holding this. - * supports — soft: the target is more anchored with it, holdable without it. - */ - type: EdgeType; - provenance: Provenance; -} - -export interface Provenance { - session: string; - /** Entry id in the session JSONL — jump-to-point. */ - entryId: string; -} - -// ─── Events (append-only ledger — the source of truth) ────────────────────── - -export type EventKind = - | "probe-correct" // pre-plan quiz, answered correctly (floor evidence) - | "probe-miss" // pre-plan quiz, wrong answer (ceiling evidence) - | "dontKnow" // "I don't know" — genuine gap, not a guess - | "misconception-signaled" // specific wrong belief identified via distractor/note - | "taught" // node established in Phase 3 - | "check-correct" // post-teach quiz-check, first try - | "check-incorrect" // post-teach quiz-check, missed - | "recall-at-delay"; // future: spaced repetition - -export interface EventEvidence { - quizQuestion: string; - /** What the learner selected (labels). */ - answer: string[]; - /** Free-text note typed with the answer — diagnostic gold. */ - note?: string; -} - -export interface KnowledgeEvent { - id: string; - node: string; - kind: EventKind; - session: string; - /** Entry id in the session JSONL → jump to the exact point where this happened. */ - entryId: string; - timestamp: number; - evidence?: EventEvidence; -} - -// ─── Mappings (merge decisions — reversible by design) ────────────────────── - -export interface SessionMapping { - /** Session-local node reference from the extraction JSON. */ - sessionNodeRef: { session: string; localId: string }; - canonicalNode: string; - decidedBy: "llm-adjudication"; - confidence: number; - /** Mirror of the session-local statement, for auditability. */ - sessionStatement: string; -} - -// ─── Knowing assessment (anchored ordinal rubric — NEVER freehand scalars) ── - -export type KnowingLevel = - | "misconception" // actively holds a wrong version - | "absent" // dontKnow or wrong with no reasoning - | "shakily-known" // correct after a miss, or correct with a hedging note - | "known" // first-try correct - | "fluent"; // correct AND the note/reasoning shows the right model - -/** Ordinal index — deterministic code maps rubric → number, never an LLM. */ -export const KNOWING_ORDER: readonly KnowingLevel[] = [ - "misconception", - "absent", - "shakily-known", - "known", - "fluent", -]; - -// ─── Extraction pipeline (per-session JSON, Stage 2 output) ───────────────── - -export interface ExtractedSession { - session: string; - nodes: Array<{ - localId: string; - statement: string; - kind: NodeKind; - domain: string; - }>; - edges: Array<{ - from: string; // localId - to: string; // localId - type: EdgeType; - }>; - events: Array<{ - node: string; // localId - kind: EventKind; - entryId: string; - evidence?: EventEvidence; - }>; - knowing: Array<{ - node: string; // localId - level: KnowingLevel; - justification: string; - }>; -} - -// ─── Derived state (computed by code, never stored as primary data) ───────── - -export interface DerivedNodeState { - node: string; - known: boolean; - knowing: number; // 0..1, from rubric + evidence events - understood: number; // 0..1, recursive over requires-subtree (aggregation semantics PARKED) - floating: boolean; // known but not understood -}