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:
@@ -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<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;
|
||||
/** Set when the partition itself (or the run as a whole) failed. */
|
||||
error?: string;
|
||||
|
||||
@@ -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<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) ──
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user