practice slice 2 — store, paths, self-contained sets

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_<ts36>_<rand>, 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.
This commit is contained in:
amosblomqvist
2026-09-29 17:53:36 +03:00
parent 05dcaafc68
commit b7a19609cb
6 changed files with 373 additions and 0 deletions
+4
View File
@@ -26,6 +26,10 @@ export const PATHS = {
/** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */ /** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */
chats: path.join(DATA_ROOT, "chats"), chats: path.join(DATA_ROOT, "chats"),
graph: path.join(DATA_ROOT, "graph"), 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; } as const;
// ─── Models ──────────────────────────────────────────────────────────────── // ─── Models ────────────────────────────────────────────────────────────────
+10
View File
@@ -128,6 +128,16 @@ export interface ProblemSet {
* decomposition derives from these + the follows-edges among them at * decomposition derives from these + the follows-edges among them at
* creation time. */ * creation time. */
nodeIds: string[]; 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<string, { name: string; statement: string }>;
/** 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; status: ProblemSetStatus;
/** Set when the partition itself (or the run as a whole) failed. */ /** Set when the partition itself (or the run as a whole) failed. */
error?: string; error?: string;
+28
View File
@@ -21,6 +21,7 @@ import type {
ExtractionRunUsage, ExtractionRunUsage,
PlanTree, PlanTree,
Position, Position,
ProblemSet,
CoverageEntry, CoverageEntry,
SessionCheckpoint, SessionCheckpoint,
TurnUsage, TurnUsage,
@@ -157,6 +158,33 @@ export interface JobStore {
deleteJobsForChat(chatId: string): number; 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<ProblemSet, "id">;
/**
* 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) ── // ─── SettingsStore — THE user settings record (settings-storage wave, 2026-09-19) ──
/** /**
+2
View File
@@ -9,6 +9,7 @@
import { chatStore } from "../store/chats.ts"; import { chatStore } from "../store/chats.ts";
import { graphStore } from "../store/graph.ts"; import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts"; import { jobStore } from "../store/jobs.ts";
import { practiceStore } from "../store/practice.ts";
import { settingsStore } from "../store/settings.ts"; import { settingsStore } from "../store/settings.ts";
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts"; import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */ /** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
@@ -16,6 +17,7 @@ export const stores = {
chatStore, chatStore,
graphStore, graphStore,
jobStore, jobStore,
practiceStore,
settingsStore, settingsStore,
}; };
+181
View File
@@ -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<string, unknown>;
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<string, unknown>).id === "string" &&
typeof (c as Record<string, unknown>).conclusion === "string" &&
isStringArray((c as Record<string, unknown>).premises),
);
const isPropositions = (v: unknown): boolean => {
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
return Object.values(v as Record<string, unknown>).every(
(p) =>
typeof p === "object" &&
p !== null &&
typeof (p as Record<string, unknown>).name === "string" &&
typeof (p as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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, "id">): 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,
};
+148
View File
@@ -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<ProblemSet, "id">> = {}): Omit<ProblemSet, "id"> => ({
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")));
});
});