From b7a19609cbbb4c90d880bd6fd610d528cb3139e3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 17:53:36 +0300 Subject: [PATCH] =?UTF-8?q?practice=20slice=202=20=E2=80=94=20store,=20pat?= =?UTF-8?q?hs,=20self-contained=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract amendment (planning ruling): ProblemSet gains propositions (nodeId → {name, statement}) and chains (the frozen derivation) — sets are documents of what WAS selected; grading/dedup/coverage never touch the live graph. config.ts PATHS.practice → data/practice/. PracticeStore port (createSet assigns ps__, getSet, listSets createdAt-desc, saveSet replace-whole, deleteSet). src/store/practice.ts: one JSON per set, hand-written isProblemSet read guard (types-only; corrupt/missing → null/skipped, never thrown; saveSet refuses invalid records). Wired in src/server/stores.ts. 10-test suite: round-trip, corrupt-tolerance, missing-dir, validator garbage-reject + optional-tolerate + verdict union, ps_ id shape. --- config.ts | 4 + src/core/contracts/practice.ts | 10 ++ src/core/ports.ts | 28 +++++ src/server/stores.ts | 2 + src/store/practice.ts | 181 +++++++++++++++++++++++++++++++++ tests/practice-store.test.ts | 148 +++++++++++++++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 src/store/practice.ts create mode 100644 tests/practice-store.test.ts diff --git a/config.ts b/config.ts index 1c1d4d7..c798f21 100644 --- a/config.ts +++ b/config.ts @@ -26,6 +26,10 @@ export const PATHS = { /** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */ chats: path.join(DATA_ROOT, "chats"), graph: path.join(DATA_ROOT, "graph"), + /** Practice problem sets (one JSON document per set — src/store/practice.ts). + * Bounded self-contained snapshots (propositions + chains frozen at + * creation), not append-only ledgers. */ + practice: path.join(DATA_ROOT, "practice"), } as const; // ─── Models ──────────────────────────────────────────────────────────────── diff --git a/src/core/contracts/practice.ts b/src/core/contracts/practice.ts index f00885f..a916df9 100644 --- a/src/core/contracts/practice.ts +++ b/src/core/contracts/practice.ts @@ -128,6 +128,16 @@ export interface ProblemSet { * decomposition derives from these + the follows-edges among them at * creation time. */ nodeIds: string[]; + /** Snapshot of every selected proposition at creation — nodeId → + * {name, statement}. The set is SELF-CONTAINED (planning ruling, + * 2026-09-29): rendering, generation, grading, and dedup never + * re-read the live graph, which keeps moving (extraction continues, + * nodes get deleted); a set is a document of what WAS selected. */ + propositions: Record; + /** The frozen chain derivation (chainsForSelection at creation time). + * Coverage checks and the problems' chain tags resolve against THIS, + * never against live edges. */ + chains: ReasoningChain[]; status: ProblemSetStatus; /** Set when the partition itself (or the run as a whole) failed. */ error?: string; diff --git a/src/core/ports.ts b/src/core/ports.ts index 359efc1..fbd2350 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -21,6 +21,7 @@ import type { ExtractionRunUsage, PlanTree, Position, + ProblemSet, CoverageEntry, SessionCheckpoint, TurnUsage, @@ -157,6 +158,33 @@ export interface JobStore { deleteJobsForChat(chatId: string): number; } +// ─── PracticeStore — problem-set documents (data/practice) ────────────────── + +/** Everything a new set needs except the store-assigned "ps_" id. */ +export type CreateProblemSetInit = Omit; + +/** + * The practice module's persistence: one JSON document per problem set + * (src/store/practice.ts adapter). Sets are bounded, SELF-CONTAINED + * snapshots — propositions + chains frozen at creation (contracts/ + * practice.ts) — so reads never join the live graph. Replace-whole + * writes like settings.json, one-file-per-record like data/jobs. + * Concurrent same-set writes are the pipeline's serialization duty + * (slice 4), not the store's — this seam owns file integrity only. + */ +export interface PracticeStore { + /** Persist a new set; the store assigns the id ("ps_" + ts36 + rand). */ + createSet(init: CreateProblemSetInit): ProblemSet; + /** The full set document, or null when unknown id / corrupt file. */ + getSet(id: string): ProblemSet | null; + /** Every set, createdAt descending (the practice page's list view). */ + listSets(): ProblemSet[]; + /** Persist the full set document (replace-whole). */ + saveSet(set: ProblemSet): void; + /** Delete a set file; false when unknown id. */ + deleteSet(id: string): boolean; +} + // ─── SettingsStore — THE user settings record (settings-storage wave, 2026-09-19) ── /** diff --git a/src/server/stores.ts b/src/server/stores.ts index c63f546..bf148ea 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -9,6 +9,7 @@ import { chatStore } from "../store/chats.ts"; import { graphStore } from "../store/graph.ts"; import { jobStore } from "../store/jobs.ts"; +import { practiceStore } from "../store/practice.ts"; import { settingsStore } from "../store/settings.ts"; import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts"; /** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */ @@ -16,6 +17,7 @@ export const stores = { chatStore, graphStore, jobStore, + practiceStore, settingsStore, }; diff --git a/src/store/practice.ts b/src/store/practice.ts new file mode 100644 index 0000000..a738ab4 --- /dev/null +++ b/src/store/practice.ts @@ -0,0 +1,181 @@ +/** + * PracticeStore — the fs JSON adapter: one file per problem set in + * data/practice/ (config.PATHS.practice). Problem sets are bounded + * SELF-CONTAINED documents (propositions + chains frozen at creation — + * contracts/practice.ts), not append-only ledgers: read-modify-write + * like settings.json, one-file-per-record like data/jobs. Reads are + * validated (isProblemSet); corrupt or unparseable files return null / + * are skipped by listSets — NEVER thrown. Writes are replace-whole, + * tab-indented, mkdir-on-write. + * + * The adapter stays deliberately DUMB about concurrency: parallel + * read-modify-write cycles on the SAME set (generator jobs finishing + * around each other) are serialized by the pipeline module (slice 4), + * not here. This module owns file integrity only. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { PATHS } from "../../config.ts"; +import type { PracticeStore } from "../core/ports.ts"; +import type { ProblemSet } from "../core/contracts.ts"; + +const PRACTICE_DIR = PATHS.practice; + +function setFile(id: string): string { + if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid problem set id: ${id}`); + return path.join(PRACTICE_DIR, `${id}.json`); +} + +// ─── validation (hand-written, read-side) ──────────────────────────────────── + +const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((x) => typeof x === "string"); + +const isParams = (v: unknown): boolean => { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + return ( + typeof p.searchSolutionRatio === "number" && + Number.isFinite(p.searchSolutionRatio) && + typeof p.cueDistance === "number" && + Number.isFinite(p.cueDistance) && + typeof p.presentation === "number" && + Number.isFinite(p.presentation) + ); +}; + +const isChains = (v: unknown): boolean => + Array.isArray(v) && + v.every( + (c) => + typeof c === "object" && + c !== null && + typeof (c as Record).id === "string" && + typeof (c as Record).conclusion === "string" && + isStringArray((c as Record).premises), + ); + +const isPropositions = (v: unknown): boolean => { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + return Object.values(v as Record).every( + (p) => + typeof p === "object" && + p !== null && + typeof (p as Record).name === "string" && + typeof (p as Record).statement === "string", + ); +}; + +const SET_STATUSES = new Set(["partitioning", "generating", "ready", "error"]); +const PROBLEM_STATUSES = new Set(["generating", "ready", "error"]); +const VERDICTS = new Set(["solid", "partial", "missed"]); + +const isProblem = (v: unknown): boolean => { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + if (typeof p.id !== "string" || !isStringArray(p.chains)) return false; + if (typeof p.statement !== "string" || typeof p.solution !== "string" || typeof p.rubric !== "string") return false; + if (typeof p.status !== "string" || !PROBLEM_STATUSES.has(p.status)) return false; + if (p.error !== undefined && typeof p.error !== "string") return false; + if (p.answer !== undefined && typeof p.answer !== "string") return false; + if (p.grade !== undefined) { + const g = p.grade as Record; + if ( + typeof g !== "object" || + g === null || + typeof g.verdict !== "string" || + !VERDICTS.has(g.verdict) || + typeof g.feedback !== "string" || + typeof g.gradedAt !== "string" + ) { + return false; + } + } + return true; +}; + +/** Read-side shape guard (exported for the store tests). Types-only: + * dial RANGES and chain invariants (id === conclusion) are creation-seam + * duties, not read duties — a file that parses and type-checks reads. */ +export function isProblemSet(value: unknown): value is ProblemSet { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.id === "string" && + v.id.startsWith("ps_") && + typeof v.title === "string" && + typeof v.createdAt === "string" && + isParams(v.params) && + isStringArray(v.nodeIds) && + isPropositions(v.propositions) && + isChains(v.chains) && + typeof v.status === "string" && + SET_STATUSES.has(v.status) && + (v.error === undefined || typeof v.error === "string") && + Array.isArray(v.problems) && + v.problems.every(isProblem) + ); +} + +// ─── the adapter ───────────────────────────────────────────────────────────── + +function writeSetFile(set: ProblemSet): void { + fs.mkdirSync(PRACTICE_DIR, { recursive: true }); + fs.writeFileSync(setFile(set.id), JSON.stringify(set, null, "\t"), "utf-8"); +} + +function createSet(init: Omit): ProblemSet { + const set: ProblemSet = { + ...init, + id: `ps_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`, + }; + writeSetFile(set); + return set; +} + +function getSet(id: string): ProblemSet | null { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(setFile(id), "utf-8")); + } catch { + return null; // unknown id or unparseable file — never thrown + } + return isProblemSet(parsed) ? parsed : null; +} + +function listSets(): ProblemSet[] { + try { + fs.mkdirSync(PRACTICE_DIR, { recursive: true }); + return fs + .readdirSync(PRACTICE_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => getSet(f.slice(0, -5))) + .filter((s): s is ProblemSet => s !== null) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } catch { + return []; // unreadable dir — the practice page shows empty + } +} + +function saveSet(set: ProblemSet): void { + if (!isProblemSet(set)) throw new Error("problem set record failed validation"); + writeSetFile(set); +} + +function deleteSet(id: string): boolean { + try { + fs.unlinkSync(setFile(id)); + return true; + } catch { + return false; // already gone — best-effort truth + } +} + +export const practiceStore: PracticeStore = { + createSet, + getSet, + listSets, + saveSet, + deleteSet, +}; diff --git a/tests/practice-store.test.ts b/tests/practice-store.test.ts new file mode 100644 index 0000000..512d4e6 --- /dev/null +++ b/tests/practice-store.test.ts @@ -0,0 +1,148 @@ +/** + * PracticeStore (slice 2, 2026-09-29): the fs adapter for problem sets — + * one JSON document per set in data/practice/ (a BRAND-NEW domain dir, so + * no snapshot/restore dance: the whole dir is ours and gets removed in + * after()). Round-trip, corrupt-tolerance, validator pins, id shape. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { after, afterEach, describe, it } from "node:test"; +import { PATHS } from "../config.ts"; +import { isProblemSet, practiceStore } from "../src/store/practice.ts"; +import type { ProblemSet } from "../src/core/contracts.ts"; + +after(() => { + fs.rmSync(PATHS.practice, { recursive: true, force: true }); +}); +afterEach(() => { + fs.rmSync(PATHS.practice, { recursive: true, force: true }); +}); + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +const init = (over: Partial> = {}): Omit => ({ + title: "Libertarianism — session practice", + createdAt: "2026-09-29T12:00:00.000Z", + params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 }, + nodeIds: ["n_a", "n_b", "n_c"], + propositions: { + n_a: { name: "Self-ownership", statement: "Each person owns themself." }, + n_b: { name: "Homesteading", statement: "Unowned goods are claimed by first use." }, + n_c: { name: "Argumentation", statement: "Arguing presupposes self-ownership." }, + }, + chains: [ + { id: "n_c", premises: ["n_a"], conclusion: "n_c" }, + { id: "n_b", premises: [], conclusion: "n_b" }, + ], + status: "partitioning", + problems: [], + ...over, +}); + +// ─── round-trip ────────────────────────────────────────────────────────────── + +describe("the practice store", () => { + it("createSet assigns a ps_ id, mkdirs, and round-trips through getSet", () => { + const set = practiceStore.createSet(init()); + assert.match(set.id, /^ps_[a-z0-9]+_[a-f0-9]{8}$/); + assert.deepEqual(practiceStore.getSet(set.id), set); + }); + + it("saveSet replaces whole — mutations persist", () => { + const set = practiceStore.createSet(init({ status: "generating" })); + set.problems.push({ + id: "pp_x", + chains: ["n_c"], + statement: "Why does arguing presuppose self-ownership?", + solution: "Because…", + rubric: "- names the performative contradiction", + status: "ready", + answer: "Because…", + grade: { verdict: "solid", feedback: "Named it.", gradedAt: "2026-09-29T13:00:00.000Z" }, + }); + set.status = "ready"; + practiceStore.saveSet(set); + const reread = practiceStore.getSet(set.id); + assert.equal(reread?.status, "ready"); + assert.equal(reread?.problems[0]?.grade?.verdict, "solid"); + }); + + it("listSets returns createdAt-descending and skips corrupt/unparseable files", () => { + const older = practiceStore.createSet(init({ createdAt: "2026-09-28T00:00:00.000Z" })); + const newer = practiceStore.createSet(init({ createdAt: "2026-09-29T00:00:00.000Z", title: "Newer" })); + fs.writeFileSync(`${PATHS.practice}/ps_corrupt.json`, "{not json", "utf-8"); + fs.writeFileSync( + `${PATHS.practice}/ps_wrongshape.json`, + JSON.stringify({ id: "ps_wrongshape", title: 42 }), + "utf-8", + ); + const listed = practiceStore.listSets(); + assert.deepEqual( + listed.map((s) => s.id), + [newer.id, older.id], + ); + assert.equal(practiceStore.getSet("ps_corrupt"), null); + assert.equal(practiceStore.getSet("ps_wrongshape"), null); + }); + + it("missing dir: listSets is [] and getSet is null, never a throw", () => { + assert.deepEqual(practiceStore.listSets(), []); + assert.equal(practiceStore.getSet("ps_missing"), null); + }); + + it("deleteSet: true then false, file gone", () => { + const set = practiceStore.createSet(init()); + assert.equal(practiceStore.deleteSet(set.id), true); + assert.equal(practiceStore.getSet(set.id), null); + assert.equal(practiceStore.deleteSet(set.id), false); + }); + + it("saveSet refuses to write an invalid record (file integrity seam)", () => { + const set = practiceStore.createSet(init()); + const bad = { ...set, status: "nonsense" } as unknown as ProblemSet; + assert.throws(() => practiceStore.saveSet(bad)); + // the original survives untouched + assert.equal(practiceStore.getSet(set.id)?.status, "partitioning"); + }); +}); + +// ─── validator pins ────────────────────────────────────────────────────────── + +describe("isProblemSet", () => { + it("accepts the full shape, all statuses, empty problems", () => { + for (const status of ["partitioning", "generating", "ready", "error"] as const) { + assert.ok(isProblemSet({ ...init({ status }), id: "ps_valid" })); + } + }); + + it("rejects garbage", () => { + assert.ok(!isProblemSet(null)); + assert.ok(!isProblemSet("set")); + assert.ok(!isProblemSet({})); + assert.ok(!isProblemSet({ ...init(), id: "xx_1" })); + assert.ok(!isProblemSet({ ...init(), params: { searchSolutionRatio: "high" } })); + assert.ok(!isProblemSet({ ...init(), chains: [{ id: "n_c", premises: "n_a", conclusion: "n_c" }] })); + assert.ok(!isProblemSet({ ...init(), propositions: { n_a: { name: "x" } } })); + assert.ok(!isProblemSet({ ...init(), status: "cooked" })); + }); + + it("tolerates optional fields — a minimal stub (no error/answer/grade) reads", () => { + const minimal = { ...init({ problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "generating" }] }), id: "ps_valid" }; + assert.ok(isProblemSet(minimal)); + }); + + it("grade verdicts are the three-level union", () => { + const withGrade = (verdict: string): unknown => ({ + ...init(), + id: "ps_valid", + problems: [ + { id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { verdict, feedback: "", gradedAt: "t" } }, + ], + }); + assert.ok(isProblemSet(withGrade("solid"))); + assert.ok(isProblemSet(withGrade("partial"))); + assert.ok(isProblemSet(withGrade("missed"))); + assert.ok(!isProblemSet(withGrade("excellent"))); + }); +});