delete the batch practice-problems module (superseded by practice sessions)
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from history if ever revisited. Removed: core practice contracts + pipeline helpers, the PracticeStore port + adapter + data dir path, server service + API routes (sets, clusters picker, practice aborts), the practice pages + dialog/exam/list UI, chain-table's Generate handoff, sidebar nav, activity-dock practice rows, and the partitioner/ problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5). Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain, chainsForSelection, formatChainLine — the chain table and graph emission still consume them.
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* THE SHARED BACKGROUND-LLM SEMAPHORE (owner ruling, 2026-09-29): ONE
|
||||
* process-wide pool of 3 permits for ALL background LLM jobs — extraction
|
||||
* chunks AND practice partitioner/generators/graders. Total background
|
||||
* chunks. Total background
|
||||
* LLM concurrency is bounded at 3, whoever is calling: no subsystem gets
|
||||
* a private lane past the provider-facing stance.
|
||||
*
|
||||
* Extraction owned this gate alone first (owner, 2026-09-28: at most 3
|
||||
* concurrent extractor calls). Practice grew its own twin that evening;
|
||||
* concurrent extractor calls).
|
||||
* the ruling then unified them — this module is the single truth, so
|
||||
* extraction.ts and practice-pipeline.ts must never cross-import.
|
||||
* extraction chunks are the only holders.
|
||||
*
|
||||
* FIFO; abort-aware (a queued caller that aborts marks its ticket dead —
|
||||
* the permit handoff skips it and hands to the next waiter).
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
/** Total background LLM concurrency, process-wide, across ALL subsystems
|
||||
* (owner ruling 2026-09-29: one shared pool — extraction chunks +
|
||||
* practice partitioner/generators/graders). */
|
||||
* extraction chunks). */
|
||||
const PERMIT_COUNT = 3;
|
||||
|
||||
export interface LlmSlots {
|
||||
|
||||
@@ -72,8 +72,7 @@ import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||
*
|
||||
* The events ledger is GONE (2026-09-27): quiz outcomes and understanding
|
||||
* observations are no longer formalized in the graph — the graph is purely
|
||||
* extracted reasoning chains (nodes + edges). Understanding-level
|
||||
* assessment is practice-module territory.
|
||||
* extracted reasoning chains (nodes + edges).
|
||||
*
|
||||
* The extractor is a single-tool-call worker (one TypeBox-validated submit
|
||||
* call — a pi agent purely so the model can retry a schema-invalid call).
|
||||
@@ -244,7 +243,7 @@ function fillFindings(raw: FindingsSubmit): FindingsSubmit {
|
||||
/** The chain laws (extractor redesign, 2026-09-30; digest-aware
|
||||
* 2026-09-30 evening) — the LOUD channel: runSubmitAgent turns a
|
||||
* problem string into an error tool result and the model re-submits
|
||||
* within its turns (the practice-proven hook). The MIXED NAMESPACE: an
|
||||
* within its turns. The MIXED NAMESPACE: an
|
||||
* endpoint resolves when it is a submitted proposition id OR an
|
||||
* existing graph node id (the extractor continues a partly-extracted
|
||||
* source and references digest ids verbatim). Identical (premise-set,
|
||||
@@ -864,8 +863,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
|
||||
/** THE EXTRACT SLOT (unified 2026-09-29): extraction chunk jobs run
|
||||
* under the SHARED background-LLM semaphore — one process-wide pool
|
||||
* of 3 permits for extraction chunks AND practice's partitioner/
|
||||
* generators/graders (owner ruling; see bg-llm-semaphore.ts). The
|
||||
* of 3 permits (see bg-llm-semaphore.ts). The
|
||||
* job's registry-row signal kills a queued ticket (dead-ticket
|
||||
* handoff). The audit side needs no slot: audits already serialize
|
||||
* behind their own global lock. */
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
/**
|
||||
* THE PRACTICE PIPELINE (practice arc, slice 4 — 2026-09-29): the
|
||||
* orchestration around the practice agents (slice 3) — set creation,
|
||||
* two-stage generation (partitioner → one generator per problem), answer
|
||||
* submission, grading, abort, and the live-run registry the activity
|
||||
* feed reads. Sibling of practice.ts (the agents + their doctrines);
|
||||
* this module owns STATE, the agents module owns CALLS.
|
||||
*
|
||||
* ARCHITECTURE (extraction's idioms, deliberately):
|
||||
* - Live runs live on globalThis.__lhPracticeRuns — PER-PROBLEM ROWS
|
||||
* (owner ruling, 2026-09-29: matching the extraction per-chunk
|
||||
* pattern): "${setId}:partition" while the partitioner runs, then one
|
||||
* "${setId}:gen:${problemId}" per problem ("queued"→"generating"),
|
||||
* and one "${setId}:grade:${problemId}" per graded answer
|
||||
* ("queued"→"grading") — because Next dev compiles each route entry
|
||||
* as its own module instance; a module-level Map would read empty
|
||||
* from /api/activity. One process, one truth (the turn/wake/abort/
|
||||
* extraction registries' lesson).
|
||||
* - Every set-file mutation funnels through the per-set write mutex
|
||||
* (the audit-lock idiom, per-set): the STORE adapter stays dumb —
|
||||
* serialization is THIS layer's job (store module's contract).
|
||||
* - Model calls (partitioner, generators, graders) gate behind the
|
||||
* SHARED background-LLM semaphore — one process-wide pool of 3
|
||||
* permits for ALL background LLM jobs, extraction chunks included
|
||||
* (owner ruling, 2026-09-29; bg-llm-semaphore.ts); abort-aware
|
||||
* while queued.
|
||||
* - The store adapter isSYNC (fs); reads may run unlocked, only
|
||||
* read-modify-write cycles take the lock.
|
||||
*
|
||||
* FIRE-AND-FORGET front doors: createProblemSet persists the set at
|
||||
* "partitioning" and returns immediately — the pipeline runs in the
|
||||
* background; the returned `done` promise is the test/diagnostic seam
|
||||
* (routes ignore it). submitAnswers mirrors it for grading. All
|
||||
* background failure paths settle the persisted record (never an
|
||||
* unhandled rejection); aborts mark "Aborted".
|
||||
*
|
||||
* ONE ATTEMPT PER PROBLEM (owner ruling 2026-09-29): a problem accepts
|
||||
* a single submission — once `answer` is set the problem is done, the
|
||||
* route refuses re-submission. Grades are per-problem: submitting
|
||||
* another problem while one grades is fine (the shared LLM pool bounds
|
||||
* parallelism); only the SAME problem's live grade row blocks (which
|
||||
* the one-attempt rule already prevents client-side).
|
||||
*/
|
||||
|
||||
import type { GraphStore, PracticeStore } from "../../core/ports.ts";
|
||||
import { emit } from "../../core/events.ts";
|
||||
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
|
||||
import { chainsForSelection } from "../../core/practice.ts";
|
||||
import { getGraph } from "../../core/graph.ts";
|
||||
import {
|
||||
createPracticeAgents,
|
||||
type PracticeModels,
|
||||
type PracticeStepLog,
|
||||
type PreviousProblemRef,
|
||||
type PropositionSnapshot,
|
||||
} from "./practice.ts";
|
||||
import { withBgLlmSlot } from "./bg-llm-semaphore.ts";
|
||||
|
||||
// ─── typed errors (the service maps these to responses) ──────────────────────
|
||||
|
||||
/** The selector resolved to nothing practicable (400 at the route). */
|
||||
export class EmptySelectionError extends Error {
|
||||
constructor(message = "selection has no propositions to practice") {
|
||||
super(message);
|
||||
this.name = "EmptySelectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Set id unknown (404). */
|
||||
export class PracticeSetNotFound extends Error {
|
||||
constructor(setId: string) {
|
||||
super(`no practice set ${setId}`);
|
||||
this.name = "PracticeSetNotFound";
|
||||
}
|
||||
}
|
||||
|
||||
/** The set exists but is not in the state the caller needs (409). */
|
||||
export class PracticeSetNotReady extends Error {
|
||||
constructor(status: ProblemSet["status"]) {
|
||||
super(`practice set is ${status}, not ready`);
|
||||
this.name = "PracticeSetNotReady";
|
||||
}
|
||||
}
|
||||
|
||||
/** Malformed answers: unknown problem, empty text, already graded, or a
|
||||
* grading run already in flight (400). */
|
||||
export class BadAnswersError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BadAnswersError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── the live-run registry (globalThis — see module doc) ─────────────────────
|
||||
|
||||
export type PracticeRunKind = "generation" | "grading";
|
||||
export type PracticePhase = "partitioning" | "queued" | "generating" | "grading";
|
||||
|
||||
/** One live ROW — per-problem, matching the extraction per-chunk pattern
|
||||
* (owner ruling, 2026-09-29). The registry key IS the row's identity:
|
||||
* the feed's id and the dock's abort target. Each row owns its OWN
|
||||
* abort controller (abort routes by row: one problem dies alone) and
|
||||
* its OWN usage accumulation (this row's model call only). */
|
||||
export interface RunningPractice {
|
||||
/** `${setId}:partition` | `${setId}:gen:${problemId}` |
|
||||
* `${setId}:grade:${problemId}` — the registry key, the feed id,
|
||||
* and the abort key in one. */
|
||||
key: string;
|
||||
setId: string;
|
||||
kind: PracticeRunKind;
|
||||
phase: PracticePhase;
|
||||
/** "Partitioning — T" | "Problem i/N — T" | "Grading i/N — T" — the
|
||||
* dock's line (progress rides the label). */
|
||||
label: string;
|
||||
startedAt: number;
|
||||
/** 1-based position within the fan-out (gen rows: the partition's
|
||||
* order; grade rows: the submitted answers' order). */
|
||||
problemIndex?: number;
|
||||
problemTotal?: number;
|
||||
/** THIS row's model-call usage (one stage per row: partitioner,
|
||||
* generator, or grader — completion-folded, not per-step). */
|
||||
usage?: PracticeStepLog;
|
||||
/** True-cancellation for THIS row's model call (the dock's abort);
|
||||
* abort() also kills a queued semaphore ticket. */
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
interface LhPracticeGlobal {
|
||||
__lhPracticeRuns?: Map<string, RunningPractice>;
|
||||
/** Per-set write mutexes (audit-lock idiom, per-set). */
|
||||
__lhPracticeLocks?: Map<string, { tail: Promise<void> }>;
|
||||
}
|
||||
|
||||
function practiceRuns(): Map<string, RunningPractice> {
|
||||
const g = globalThis as typeof globalThis & LhPracticeGlobal;
|
||||
return (g.__lhPracticeRuns ??= new Map());
|
||||
}
|
||||
|
||||
function practiceLocks(): Map<string, { tail: Promise<void> }> {
|
||||
const g = globalThis as typeof globalThis & LhPracticeGlobal;
|
||||
return (g.__lhPracticeLocks ??= new Map());
|
||||
}
|
||||
|
||||
/** In-flight practice runs, oldest first (the feed sorts again; this is
|
||||
* the stable, deterministic order tests pin). */
|
||||
export function practiceStatuses(): RunningPractice[] {
|
||||
return [...practiceRuns().values()].sort((a, b) => a.startedAt - b.startedAt);
|
||||
}
|
||||
|
||||
/** The per-set serialized write section: read-modify-write cycles around
|
||||
* one set's file queue here, process-wide. */
|
||||
async function withSetLock<T>(setId: string, critical: () => Promise<T> | T): Promise<T> {
|
||||
const locks = practiceLocks();
|
||||
const prev = locks.get(setId)?.tail ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const mine = new Promise<void>((resolve) => (release = resolve));
|
||||
locks.set(setId, { tail: prev.then(() => mine) });
|
||||
await prev;
|
||||
try {
|
||||
return await critical();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── small helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** "pp_" + ts36 + rand — mirrors the store's ps_ minting. */
|
||||
function newProblemId(): string {
|
||||
return `pp_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function isAbortLike(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === "AbortError";
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function registerRow(
|
||||
key: string,
|
||||
setId: string,
|
||||
kind: PracticeRunKind,
|
||||
phase: PracticePhase,
|
||||
label: string,
|
||||
part?: { i: number; n: number },
|
||||
): RunningPractice {
|
||||
const row: RunningPractice = {
|
||||
key,
|
||||
setId,
|
||||
kind,
|
||||
phase,
|
||||
label,
|
||||
startedAt: Date.now(),
|
||||
problemIndex: part?.i,
|
||||
problemTotal: part?.n,
|
||||
controller: new AbortController(),
|
||||
};
|
||||
practiceRuns().set(key, row);
|
||||
// The dock learns the row at the moment of cause (push S5, 2026-10-02):
|
||||
// notify-after-write — the registry is the feed's truth.
|
||||
emit("activity-changed", {});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Row over (settled, failed, aborted — every exit): the row leaves the
|
||||
* feed. ONE seam for all delete sites so no exit path can miss the
|
||||
* announce (push S5, 2026-10-02). */
|
||||
function finishPractice(key: string): void {
|
||||
practiceRuns().delete(key);
|
||||
emit("activity-changed", {});
|
||||
}
|
||||
|
||||
/** DEDUP INPUT (computed ONCE at creation, passed to partitioner and
|
||||
* every generator): ready problems from the most recent ≤5 sets sharing
|
||||
* at least one selected node. */
|
||||
function dedupInput(practiceStore: PracticeStore, selected: ReadonlySet<string>): PreviousProblemRef[] {
|
||||
const refs: PreviousProblemRef[] = [];
|
||||
let setsSeen = 0;
|
||||
for (const set of practiceStore.listSets()) {
|
||||
if (setsSeen >= 5) break;
|
||||
if (!set.nodeIds.some((id) => selected.has(id))) continue;
|
||||
setsSeen++;
|
||||
for (const p of set.problems) {
|
||||
if (p.status === "ready" && p.statement !== "") refs.push({ statement: p.statement, chains: p.chains });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
// ─── the pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PracticePipelineDeps extends PracticeModels {
|
||||
graphStore: GraphStore;
|
||||
practiceStore: PracticeStore;
|
||||
}
|
||||
|
||||
export function createPracticePipeline(deps: PracticePipelineDeps) {
|
||||
const agents = createPracticeAgents(deps);
|
||||
|
||||
// BOOT SWEEP (owner bug fix, live test 2026-09-29 — the sweep once
|
||||
// stamped a LIVE run): a persisted partitioning/generating set with no
|
||||
// live row AND a heartbeat older than the grace window is a crash/
|
||||
// restart orphan (the registry is memory-only) — mark it, idempotently.
|
||||
// THREE guards against stamping the living:
|
||||
// 1. ONCE PER PROCESS (globalThis flag) — an instance could otherwise
|
||||
// evaluate against a registry whose fresh row it can't observe
|
||||
// (dev route bundles, restart paths).
|
||||
// 2. GRACE WINDOW (120s over updatedAt ?? createdAt) — a live run's
|
||||
// WATCHDOG re-stamps the file every 30s (cross-worker liveness —
|
||||
// the registry is NOT shared between dev route workers), so a
|
||||
// live run is structurally never 120s stale; a true crash stops
|
||||
// the beats and is minutes stale at the next boot.
|
||||
// 3. Live-row check (globalThis-shared registry) as before.
|
||||
const sweepFlag = globalThis as { __lhPracticeSweepDone?: boolean };
|
||||
if (!sweepFlag.__lhPracticeSweepDone) {
|
||||
sweepFlag.__lhPracticeSweepDone = true;
|
||||
const staleBefore = Date.now() - 120_000;
|
||||
for (const set of deps.practiceStore.listSets()) {
|
||||
if (set.status !== "partitioning" && set.status !== "generating") continue;
|
||||
let live = false;
|
||||
for (const key of practiceRuns().keys()) if (key.startsWith(`${set.id}:`)) { live = true; break; }
|
||||
if (live) continue;
|
||||
const heartbeat = Date.parse(set.updatedAt ?? set.createdAt);
|
||||
if (Number.isFinite(heartbeat) && heartbeat > staleBefore) continue; // grace
|
||||
console.error(`[practice ${set.id}] boot sweep: stale ${set.status} set marked interrupted`);
|
||||
set.status = "error";
|
||||
set.error = "Interrupted (server restarted)";
|
||||
deps.practiceStore.saveSet(set);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the error state on a set (partitioner failure / abort). */
|
||||
async function markSetError(setId: string, message: string): Promise<void> {
|
||||
await withSetLock(setId, () => {
|
||||
const cur = deps.practiceStore.getSet(setId);
|
||||
if (!cur) return;
|
||||
cur.status = "error";
|
||||
cur.error = message;
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
}
|
||||
|
||||
// ── generation ───────────────────────────────────────────────────────
|
||||
|
||||
async function generateSet(
|
||||
set: ProblemSet,
|
||||
previousProblems: PreviousProblemRef[],
|
||||
partitionRow: RunningPractice,
|
||||
note?: string,
|
||||
): Promise<void> {
|
||||
const controller = partitionRow.controller;
|
||||
|
||||
// PERSISTENT HEARTBEAT (owner live test #2, 2026-09-29 — the sweep
|
||||
// stamped LIVE runs again): dev serves routes from workers that do
|
||||
// NOT share this process's registry, so memory liveness cannot
|
||||
// protect a cross-worker sweep, and a long-running last problem can
|
||||
// exceed the 120s grace between store writes. The watchdog
|
||||
// re-stamps updatedAt on the FILE every 30s while THIS run is live
|
||||
// (4-beat tolerance inside the grace window) — any process's sweep
|
||||
// then sees a fresh heartbeat; a true crash stops the beats and the
|
||||
// orphan goes stale honestly.
|
||||
const beat = setInterval(() => {
|
||||
void withSetLock(set.id, () => {
|
||||
const cur = deps.practiceStore.getSet(set.id);
|
||||
if (!cur) return;
|
||||
if (cur.status !== "partitioning" && cur.status !== "generating") return;
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
}, 30_000);
|
||||
beat.unref?.();
|
||||
|
||||
try {
|
||||
// Stage 1 — the partitioner (its OWN dock row; acquires a shared
|
||||
// permit like every background LLM job).
|
||||
let partitions: Array<{ chains: string[] }>;
|
||||
try {
|
||||
const submitted = await withBgLlmSlot(controller.signal, () =>
|
||||
agents.runPartitioner({
|
||||
chains: set.chains,
|
||||
propositions: set.propositions,
|
||||
params: set.params,
|
||||
previousProblems,
|
||||
note,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
partitionRow.usage = submitted.log;
|
||||
partitions = submitted.partitions;
|
||||
} catch (err) {
|
||||
await markSetError(set.id, isAbortLike(err) || controller.signal.aborted ? "Aborted" : messageOf(err));
|
||||
finishPractice(partitionRow.key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stubs appear the moment the partition lands (numbered, generating);
|
||||
// per-problem gen rows take the partition row's place in the dock.
|
||||
const stubs: Problem[] = partitions.map((part) => ({
|
||||
id: newProblemId(),
|
||||
chains: part.chains,
|
||||
statement: "",
|
||||
solution: "",
|
||||
rubric: "",
|
||||
status: "generating",
|
||||
}));
|
||||
const saved = await withSetLock(set.id, () => {
|
||||
const cur = deps.practiceStore.getSet(set.id);
|
||||
if (!cur) return null;
|
||||
cur.problems = stubs;
|
||||
cur.status = "generating";
|
||||
deps.practiceStore.saveSet(cur);
|
||||
return cur;
|
||||
});
|
||||
finishPractice(partitionRow.key);
|
||||
if (!saved) return; // deleted mid-run — stop quietly
|
||||
|
||||
const genRows = stubs.map((stub, i) =>
|
||||
registerRow(
|
||||
`${set.id}:gen:${stub.id}`,
|
||||
set.id,
|
||||
"generation",
|
||||
"queued",
|
||||
`Problem ${i + 1}/${stubs.length} — ${set.title}`,
|
||||
{ i: i + 1, n: stubs.length },
|
||||
),
|
||||
);
|
||||
|
||||
// Stage 2 — one generator per problem, failure-isolated, each its
|
||||
// own row + abort controller (a row abort kills THAT problem only).
|
||||
await Promise.all(
|
||||
stubs.map(async (stub, i) => {
|
||||
const row = genRows[i]!; // 1:1 with stubs by construction
|
||||
try {
|
||||
await withBgLlmSlot(row.controller.signal, async () => {
|
||||
if (row.controller.signal.aborted) {
|
||||
const err = new Error("aborted while queued");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
row.phase = "generating";
|
||||
emit("activity-changed", {}); // in-place phase write — announce it
|
||||
const generated = await agents.runProblemGenerator({
|
||||
target: stub.chains,
|
||||
fullChains: set.chains,
|
||||
propositions: set.propositions,
|
||||
params: set.params,
|
||||
previousProblems,
|
||||
note,
|
||||
signal: row.controller.signal,
|
||||
});
|
||||
row.usage = generated.log;
|
||||
await withSetLock(set.id, () => {
|
||||
const cur = deps.practiceStore.getSet(set.id);
|
||||
if (!cur) return;
|
||||
const p = cur.problems.find((q) => q.id === stub.id);
|
||||
if (!p) return;
|
||||
p.chains = generated.problem.chains;
|
||||
p.statement = generated.problem.statement;
|
||||
p.solution = generated.problem.solution;
|
||||
p.rubric = generated.problem.rubric;
|
||||
p.status = "ready";
|
||||
p.error = undefined;
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Failure isolation: this problem errors, the set runs on.
|
||||
const aborted = isAbortLike(err) || row.controller.signal.aborted;
|
||||
await withSetLock(set.id, () => {
|
||||
const cur = deps.practiceStore.getSet(set.id);
|
||||
if (!cur) return;
|
||||
const p = cur.problems.find((q) => q.id === stub.id);
|
||||
if (!p) return;
|
||||
p.status = "error";
|
||||
p.error = aborted ? "Aborted" : messageOf(err);
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
} finally {
|
||||
finishPractice(row.key);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Settle: ready even with errored problems (per-problem isolation).
|
||||
// A SET-level abort already wrote its verdict synchronously
|
||||
// (abortPractice) — re-reading under the lock, we keep it.
|
||||
await withSetLock(set.id, () => {
|
||||
const cur = deps.practiceStore.getSet(set.id);
|
||||
if (!cur) return;
|
||||
if (cur.status === "error") return; // set-abort's mark stands
|
||||
cur.status = "ready";
|
||||
cur.error = undefined;
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
} finally {
|
||||
clearInterval(beat);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a problem set — fire-and-forget: persists the set at
|
||||
* "partitioning" and returns it immediately; `done` resolves when
|
||||
* generation has fully settled (any end state). Refusal gates
|
||||
* (models/key) fire BEFORE anything is created.
|
||||
*/
|
||||
async function createProblemSet(
|
||||
nodeIds: string[],
|
||||
params: ProblemSet["params"],
|
||||
titleHint?: string,
|
||||
note?: string,
|
||||
): Promise<{ set: ProblemSet; done: Promise<void> }> {
|
||||
agents.refuseIfStagesUnready();
|
||||
|
||||
// Graph-domain selection (owner ruling 2026-09-29: problem sets
|
||||
// take NODES — the UI resolves a picked cluster to its member ids;
|
||||
// unknown ids simply drop, an empty live intersection refuses).
|
||||
const wanted = new Set(nodeIds);
|
||||
const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
|
||||
const selectedIds = new Set(selected.map((n) => n.id));
|
||||
// The STORED chains (chains-primary, owner 2026-09-30): chainsForSelection
|
||||
// keeps a chain iff every member is selected — the same inclusion law as
|
||||
// the pre-chains edge grouping; never the derived fan-out.
|
||||
const chains = chainsForSelection(selected, getGraph(deps.graphStore).chains);
|
||||
if (chains.length === 0) throw new EmptySelectionError();
|
||||
const propositions: PropositionSnapshot = {};
|
||||
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
|
||||
const title = titleHint ?? `Practice — ${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
const set = deps.practiceStore.createSet({
|
||||
title,
|
||||
createdAt: new Date().toISOString(),
|
||||
params,
|
||||
nodeIds: [...selectedIds].sort(),
|
||||
propositions,
|
||||
chains,
|
||||
status: "partitioning",
|
||||
problems: [],
|
||||
...(note ? { note } : {}),
|
||||
});
|
||||
|
||||
const previousProblems = dedupInput(deps.practiceStore, selectedIds);
|
||||
const row = registerRow(`${set.id}:partition`, set.id, "generation", "partitioning", `Partitioning — ${title}`);
|
||||
const done = generateSet(set, previousProblems, row, note);
|
||||
return { set, done };
|
||||
}
|
||||
|
||||
// ── grading ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Save answers and spawn one grading job per answered problem —
|
||||
* fire-and-forget like creation: the persisted set carries the
|
||||
* answers immediately; grades land as their jobs settle.
|
||||
*/
|
||||
async function submitAnswers(
|
||||
setId: string,
|
||||
answers: Array<{ problemId: string; text: string }>,
|
||||
): Promise<{ set: ProblemSet; done: Promise<void> }> {
|
||||
agents.refuseIfStagesUnready(); // grading needs its engine — refuse BEFORE saving answers
|
||||
for (const a of answers) {
|
||||
if (practiceRuns().has(`${setId}:grade:${(a as { problemId?: unknown }).problemId}`)) {
|
||||
throw new BadAnswersError("this problem's grade is already in flight");
|
||||
}
|
||||
}
|
||||
const set = await withSetLock(setId, () => {
|
||||
const cur = deps.practiceStore.getSet(setId);
|
||||
if (!cur) throw new PracticeSetNotFound(setId);
|
||||
if (cur.status !== "ready") throw new PracticeSetNotReady(cur.status);
|
||||
const ids = new Set(cur.problems.map((p) => p.id));
|
||||
for (const a of answers) {
|
||||
if (typeof a.problemId !== "string" || !ids.has(a.problemId)) throw new BadAnswersError(`unknown problem id`);
|
||||
if (typeof a.text !== "string" || a.text.trim() === "") throw new BadAnswersError("answers must be non-empty text");
|
||||
const p = cur.problems.find((q) => q.id === a.problemId)!;
|
||||
// ONE attempt (owner 2026-09-29): an existing answer ends the
|
||||
// problem — graded or not, it is never re-submitted.
|
||||
if (p.answer !== undefined) throw new BadAnswersError(`problem ${a.problemId} was already answered`);
|
||||
}
|
||||
for (const a of answers) {
|
||||
const p = cur.problems.find((q) => q.id === a.problemId)!;
|
||||
p.answer = a.text;
|
||||
}
|
||||
deps.practiceStore.saveSet(cur);
|
||||
return cur;
|
||||
});
|
||||
|
||||
const gradeRows = answers.map((a, i) =>
|
||||
registerRow(
|
||||
`${setId}:grade:${a.problemId}`,
|
||||
setId,
|
||||
"grading",
|
||||
"queued",
|
||||
`Grading ${i + 1}/${answers.length} — ${set.title}`,
|
||||
{ i: i + 1, n: answers.length },
|
||||
),
|
||||
);
|
||||
const done = (async () => {
|
||||
await Promise.all(answers.map((a, i) => gradeOne(setId, a.problemId, gradeRows[i]!))); // 1:1
|
||||
})();
|
||||
return { set, done };
|
||||
}
|
||||
|
||||
/** One grading job — its OWN row (key `${setId}:grade:${problemId}`);
|
||||
* abort/failure drops the grade (answer kept, re-submittable later);
|
||||
* success writes it under the set lock. */
|
||||
async function gradeOne(setId: string, problemId: string, row: RunningPractice): Promise<void> {
|
||||
const controller = row.controller;
|
||||
try {
|
||||
await withBgLlmSlot(controller.signal, async () => {
|
||||
if (controller.signal.aborted) return; // dropped — no grade, no error state
|
||||
row.phase = "grading";
|
||||
emit("activity-changed", {}); // in-place phase write — announce it
|
||||
const set = deps.practiceStore.getSet(setId);
|
||||
if (!set) return;
|
||||
const problem = set.problems.find((p) => p.id === problemId);
|
||||
if (!problem || problem.grade) return;
|
||||
// The legend scoped to THIS problem's chains (its premises +
|
||||
// conclusions) — the grader sees only what it grades.
|
||||
const legend: PropositionSnapshot = {};
|
||||
for (const chainId of problem.chains) {
|
||||
const chain: ReasoningChain | undefined = set.chains.find((c) => c.id === chainId);
|
||||
if (!chain) continue;
|
||||
for (const nodeId of [chain.conclusion, ...chain.premises]) {
|
||||
const p = set.propositions[nodeId];
|
||||
if (p) legend[nodeId] = p;
|
||||
}
|
||||
}
|
||||
const graded = await agents.runGrader({
|
||||
problem: { statement: problem.statement, solution: problem.solution, rubric: problem.rubric },
|
||||
propositions: legend,
|
||||
answer: problem.answer ?? "",
|
||||
signal: controller.signal,
|
||||
});
|
||||
row.usage = graded.log;
|
||||
await withSetLock(setId, () => {
|
||||
const cur = deps.practiceStore.getSet(setId);
|
||||
if (!cur) return;
|
||||
const p = cur.problems.find((q) => q.id === problemId);
|
||||
if (!p || p.grade) return;
|
||||
p.grade = { score: graded.score, reasoning: graded.reasoning, gradedAt: new Date().toISOString() };
|
||||
deps.practiceStore.saveSet(cur);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Grading failure: the grade simply stays absent (the answer
|
||||
// stands — one attempt; the UI shows "grade interrupted").
|
||||
console.error(`[practice] grading failed for ${problemId}:`, isAbortLike(err) ? "aborted" : messageOf(err));
|
||||
} finally {
|
||||
finishPractice(row.key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── abort + delete ───────────────────────────────────────────────────
|
||||
|
||||
/** True-cancellation for ONE row (the dock's abort — id = the row
|
||||
* key). Routes by shape: a gen-row abort kills THAT problem only
|
||||
* (siblings continue; the failure-isolation catch writes "Aborted");
|
||||
* a partition-row abort ends the whole run (the partitioner's catch
|
||||
* writes the set error); a grade-row abort drops that grade (the
|
||||
* answer stays, re-submittable). Idempotent by design (a gone row
|
||||
* is success — the blob is gone either way). */
|
||||
function abortPracticeRow(key: string): void {
|
||||
const row = practiceRuns().get(key);
|
||||
if (!row) return;
|
||||
row.controller.abort();
|
||||
finishPractice(key);
|
||||
}
|
||||
|
||||
/** SET-level abort — composes from row aborts (every live row of the
|
||||
* set) plus the synchronous persisted "Aborted" marks (a set-abort's
|
||||
* verdict must stand before the generators' settles re-read it).
|
||||
* Idempotent like abortExtraction. */
|
||||
function abortPractice(setId: string): void {
|
||||
for (const key of [...practiceRuns().keys()]) {
|
||||
if (key.startsWith(`${setId}:`)) abortPracticeRow(key);
|
||||
}
|
||||
const set = deps.practiceStore.getSet(setId);
|
||||
if (!set) return;
|
||||
let dirty = false;
|
||||
for (const p of set.problems) {
|
||||
if (p.status === "generating") {
|
||||
p.status = "error";
|
||||
p.error = "Aborted";
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (set.status === "partitioning" || set.status === "generating") {
|
||||
set.status = "error";
|
||||
set.error = "Aborted";
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) deps.practiceStore.saveSet(set);
|
||||
}
|
||||
|
||||
/** Delete a set: live jobs die first, then the record. Boolean like
|
||||
* the store (false = never existed → the route's 404). */
|
||||
async function deleteProblemSet(setId: string): Promise<boolean> {
|
||||
abortPractice(setId);
|
||||
let existed = false;
|
||||
await withSetLock(setId, () => {
|
||||
existed = deps.practiceStore.deleteSet(setId);
|
||||
});
|
||||
return existed;
|
||||
}
|
||||
|
||||
return {
|
||||
createProblemSet,
|
||||
submitAnswers,
|
||||
abortPractice,
|
||||
abortPracticeRow,
|
||||
deleteProblemSet,
|
||||
};
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* The practice agents (practice arc, slice 3 — 2026-09-29): partitioner,
|
||||
* problem-generator, grader — the LLM stages of the practice pipeline.
|
||||
* Three single-submit workers on the shared runSubmitAgent seam (one
|
||||
* TypeBox-validated submit call each; a pi agent purely so the model can
|
||||
* retry a schema-invalid or LAW-violating call — the validate hook throws
|
||||
* an invalidInputResult isError result at the execute seam (pi 1.0 convention)
|
||||
* result the model sees).
|
||||
*
|
||||
* Roles: partitioner | problemGenerator | grader — all strong-tier
|
||||
* (config.ts ROLE_SLOT; problem engineering is the quality-critical
|
||||
* component). Engines resolve per stage exactly like extraction: test
|
||||
* override → engineFor (role override → slot → nothing) → honest refusal
|
||||
* (ModelsNotConfiguredError / ProviderKeyMissingError) before anything
|
||||
* runs. NO registry here: each runner returns its own step log (model +
|
||||
* per-step CallUsage); the pipeline (slice 4) owns live state, sets,
|
||||
* serialization, and the money channel.
|
||||
*
|
||||
* The GENERATOR's doctrine is the owner's problem-engineering framework
|
||||
* VERBATIM (2026-09-28 18.10, minus the Sources line) with an appended
|
||||
* wiring section (Part III). The partitioner and grader doctrines are
|
||||
* owner-approved drafts of the same date.
|
||||
*/
|
||||
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { Type, type Static } from "typebox";
|
||||
import type { ModelRole } from "../../../config.ts";
|
||||
import {
|
||||
ModelsNotConfiguredError,
|
||||
ProviderKeyMissingError,
|
||||
ROLE_SLOT,
|
||||
type ModelSlotName,
|
||||
type ProviderKeyMissing,
|
||||
} from "../../core/settings.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubmitAgent } from "./submit-agent.ts";
|
||||
import { callUsage } from "./extraction.ts";
|
||||
import type { CallUsage, PracticeParams, ReasoningChain } from "../../core/contracts.ts";
|
||||
import { chainCoverage, CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "../../core/practice.ts";
|
||||
|
||||
// ─── doctrines ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const GENERATOR_DOCTRINE = `# The problem engineering framework
|
||||
|
||||
## I. Theory
|
||||
|
||||
### What you are doing
|
||||
|
||||
You receive a graph: propositions as nodes, derivations as edges. Teaching installed this graph in the learner. You add nothing to it and change nothing in it. What you change is when the held propositions come to mind.
|
||||
|
||||
Knowing a proposition and having it come to mind in the right situation are different states. After a lesson, a proposition comes to mind in approximately one kind of situation — the lesson's. A learner can hold a complete graph and be unable to use it, because the propositions come to mind only in the lesson's situation. Your problems exist to widen that.
|
||||
|
||||
This is why you are not writing exam questions. An exam question is built to produce evidence about the learner, so that someone can judge them. A practice question is built to place a demand on the learner, so that the demand strengthens them. An exam needs only the cheapest demand that can be graded. You need the demand that strengthens the most.
|
||||
|
||||
### What solving is, and what it strengthens
|
||||
|
||||
Solving is a loop. The learner looks at the present situation — the givens and everything derived so far — and must think of a proposition that applies; applying it produces the next situation; this repeats until the task is met. One move is one proposition brought to mind and used.
|
||||
|
||||
Each successful recall does three things. That kind of situation is afterward more likely to bring the proposition to mind — and if the situation is a new kind, the gain is a new kind of situation in which it appears. The proposition is re-recorded with features of the situation. And the strengthening is larger when the recall was harder — an unfamiliar situation, or several similar propositions competing — while a failed recall strengthens almost nothing, except through the explanation that follows it.
|
||||
|
||||
What accumulates is, for each proposition, the set of situations in which it comes to mind. Command of a subject is that set covering the situations in which the knowledge is actually used. Variety has a further effect: what stays the same across many different situations is the proposition itself, with none of the situations attached. This is what transfer is, and it is why varied practice produces transfer while identical repetition does not.
|
||||
|
||||
Build by these corollaries:
|
||||
|
||||
- Recitation is not practice. It trains the proposition to appear in one situation — the lesson's — which will not occur again.
|
||||
- Restudy is not a substitute for problems. Rereading presents the lesson's situation again, so it strengthens recall in the one place it is already strong.
|
||||
- A demand that costs nothing strengthens nothing; a demand that cannot be met strengthens nothing. Both edges matter, always.
|
||||
|
||||
### What a problem is
|
||||
|
||||
A problem exercises whatever any correct solution must pass through: the propositions that must be used, and the derivations that must be traversed. Not what you intended — what every correct solution requires.
|
||||
|
||||
A problem that can be solved by a route skipping your target teaches that route instead. Such a problem is unsound and must never be emitted.
|
||||
|
||||
### The three distances
|
||||
|
||||
**Solution distance** is the number of moves in the shortest correct solution. One move is one proposition brought to mind and used; a bare recall is one move, a derivation step is one move. Solution distance is fixed by the target — a chain of four edges cannot be exercised in three moves — and it is kept minimal, because a short solution can be held in mind as a whole and understood when found.
|
||||
|
||||
**Search distance** is the number of plausible wrong moves the learner must consider and reject to find each right one. It is manufactured: by givens that suggest wrong methods, by information that no correct solution uses, by situations that resemble the ones where other propositions apply.
|
||||
|
||||
**Cue distance** is how far the problem's situation is from every situation in which the learner has already used the targeted propositions, the lesson included.
|
||||
|
||||
### The two axes
|
||||
|
||||
The main axis is the ratio of search distance to solution distance: how much searching the problem demands per step of actual solution. At the bottom of the axis, the applicable proposition is effectively announced. At the top, finding it is most of the work.
|
||||
|
||||
The bottom is the flashcard: one move of pure recitation, search distance near zero — and cue distance near zero too, since the card front is itself the cue. Spaced repetition is this framework with both knobs at zero and the graph collapsed to isolated nodes. Recitation from a card is legitimate practice for exactly one kind of proposition: one with no derivational work and a fixed use-cue. For anything derivable, the flashcard leaves the edges untraveled.
|
||||
|
||||
The top is the competition problem, which is the ideal:
|
||||
|
||||
- solution distance minimal — the solution, once found, can be seen whole and is obvious in retrospect;
|
||||
- search distance as large as it can be while every wrong turn remains resolvable;
|
||||
- fair — nothing required beyond the learned graph;
|
||||
- checkable — the learner can verify the answer without an authority.
|
||||
|
||||
Search has an optimum, not a maximum. A resolved wrong turn teaches the boundary — when the proposition applies and when it does not. An unresolved wrong turn teaches nothing, or the wrong thing. Search is productive up to the learner's limit and destructive past it; the ideal problem sits just under the limit.
|
||||
|
||||
The second axis is cue distance, and it must sit in a band. Too small, and the problem is recitation. Too large, and the learner cannot see that the proposition applies: the recall fails, and nothing is strengthened. The usable range is new-but-recognizable.
|
||||
|
||||
### The operations
|
||||
|
||||
Three operations modify a problem at any position on the axes.
|
||||
|
||||
**Reverse.** Hand over the conclusion and demand the grounds: not "what follows from A" but "what does B rest on". Installation records the forward direction only, so the backward direction is unavailable until practiced. Being able to use B and being able to say what B rests on are different states, and the second is what explaining is.
|
||||
|
||||
**Discriminate.** Where two propositions apply to almost the same situations, present a situation that fits both almost equally, and demand the choice. The learner must find the feature that separates them. This is often the real difficulty in applied subjects: not executing the method but choosing it. Discrimination is the one place where small cue distance is wanted — the situations must be nearly identical, so that the separating feature carries all the signal.
|
||||
|
||||
**Probe the premises.** Take a derivation, remove or weaken one of its premises, and ask what breaks, and where. A derivation in use is one move; this operation opens the move and asks which premise was doing the work. It converts knowing that a step holds into knowing why. Build it only where the derivation's internal steps are known: where the step is stored as a single unit, there is nothing to open.
|
||||
|
||||
### The ladder
|
||||
|
||||
The main axis, bottom to top — five rungs, numbered; the request arrives as a rung:
|
||||
|
||||
- **0 Worked example** — the solution is shown; nothing is demanded. An installation object; nothing yet to recall.
|
||||
- **1 Completion** — the path is shown with a gap; the learner supplies one step. What to recall is nearly announced.
|
||||
- **2 Direct exercise** — one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing.
|
||||
- **3 Composition** — several steps, chained; the path is narrow but must be held and run.
|
||||
- **4 Path-finding** — several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape.
|
||||
|
||||
The ladder is the main axis only. Cue distance and the three operations apply at every point.
|
||||
|
||||
### Presentation
|
||||
|
||||
The same problem can be wrapped in a real-world story or stated as pure theory. Nothing structural changes. This is a preference of the learner, adjustable at no cost, and never a substitute for moving on the axes.
|
||||
|
||||
### Coverage
|
||||
|
||||
A single contact with a proposition is not coverage. A proposition is covered when it has been:
|
||||
|
||||
- used forward;
|
||||
- used backwards, where a backwards question is meaningful;
|
||||
- used in several situations far enough apart to be genuinely different;
|
||||
- chosen against its nearest confusables, where such neighbors exist;
|
||||
- and, for a derivation, probed in its premises, where the internal steps are known.
|
||||
|
||||
The optimal set is the smallest sound set that covers every proposition and derivation in the target graph in this sense. Read the mix off the graph's shape: a subject that is many propositions and few derivations collapses to recall in varied situations plus discrimination among confusables — spaced, interleaved flashcards, done properly. A derivation-dense subject is dominated by composition and path-finding. One framework; the mix is a property of the input.
|
||||
|
||||
## II. Process
|
||||
|
||||
Build one problem at a time through these steps. Emit nothing that has not passed all of them.
|
||||
|
||||
### 1. Select the target
|
||||
|
||||
From the graph and the given parameters: an edge; a chain of edges; a node; a cluster of mutually confusable propositions; an edge plus one of its premises. Degrade gracefully — whatever structure the input has determines which targets exist. A lone node with no edges yields only execution and discrimination problems; that is correct behavior, not a failure.
|
||||
|
||||
### 2. Set the parameters
|
||||
|
||||
The request arrives with three parameters: the search-to-solution ratio, the cue distance, and the presentation. Solution distance remains the target's own length — do not pad it — so the given ratio sets the search distance: as many plausible wrong moves per solution step as the ratio demands, concentrated on a short target or distributed along a chain. Hold both distances to their bands: search only as large as remains resolvable; cue new-but-recognizable. If the given parameters exceed what the target supports — nothing to discriminate against, no resolvable wrong turns available, no distance left from the lesson — lower the parameter and say so. Do not fake it.
|
||||
|
||||
### 3. Choose the operations
|
||||
|
||||
The operations are not part of the input; they are yours to choose. Reverse, discriminate, probe the premises — apply them where meaningful for the target, and skip them where they are not. They are available techniques, not obligations.
|
||||
|
||||
### 4. Build the situation
|
||||
|
||||
Re-bind the target into a concrete situation at the chosen cue distance: different from the lesson's situation, and different from every situation already used for this target elsewhere in the set. For search distance, add distractor givens that suggest wrong methods and decoy routes that fail — each for a reason the learner can discover. State the task so the target is required but not announced. Apply the given presentation wrapper last; it changes nothing structural.
|
||||
|
||||
### 5. Solve it, then try to break it
|
||||
|
||||
Write the model solution: every move, in order. Then check, in this order:
|
||||
|
||||
- **Necessity.** The solution you wrote uses the targeted steps. The target is genuinely forced.
|
||||
- **Shortcut hunt.** Actively search for a different or shorter solution that skips the target. If one exists, the problem is unsound: fix it or discard it. Emit only problems you have tried to break and could not.
|
||||
- **Resolvability.** For each planted wrong turn, a reason it fails is discoverable — from the givens, or from the model solution after the attempt.
|
||||
- **Fairness and checkability.** The solution uses nothing outside the graph, and the learner can verify the answer without an authority.
|
||||
|
||||
### 6. Check the set
|
||||
|
||||
When the set is assembled: every node and edge covered in the repertoire sense; no two problems for the same target in the same kind of situation; confusables met in at least one discrimination; no problem removable without breaking coverage; every problem having passed step 5.
|
||||
|
||||
### Standing prohibitions
|
||||
|
||||
- Never emit a problem you have not solved.
|
||||
- Never emit a problem you have not tried to shortcut.
|
||||
- Never require anything outside the graph.
|
||||
- Never announce the target. If the situation makes the applicable proposition unmistakable, that is a direct exercise — it must be intended as one, not arrived at by accident.
|
||||
- Never build for gradability over strengthening. You impose demands; you do not extract evidence.
|
||||
|
||||
## III. Inputs and outputs (the practice module's wiring)
|
||||
|
||||
You are one generator in a two-stage pipeline: a partitioner has already divided the target into problems; you build exactly ONE of them.
|
||||
|
||||
INPUTS (in the user message):
|
||||
- YOUR TARGET: the chains this problem is built to strengthen — the partitioner's assignment.
|
||||
- THE FULL SET: the proposition legend and chain list of the ENTIRE selection, not just the target. A coherent problem often cannot be built from the target alone — you may draw on other propositions of the full set as supporting material when the problem needs it. DOCTRINE: minimal use of outside propositions, maximal focus on the target — the target is what the learner must recall; outside propositions are material the solution may pass through. The never-announce rule covers every proposition, target or not.
|
||||
- THE PARAMETERS: three settings, each a named level — never a number to interpolate.
|
||||
THE LADDER RUNG (the main axis): 0 Worked example · 1 Completion · 2 Direct exercise · 3 Composition · 4 Path-finding (Part I's ladder). The invocation names your rung; build exactly that CLASS of problem. There is no continuum between rungs — a rung-3 problem is composition, not "composition with a little path-finding."
|
||||
THE CUE DISTANCE: 0 Lesson context · 1 Adjacent context · 2 Far context — how far the situation sits from the cues the learner has already met: the lesson's own examples AND every previous problem's situation. Far context is the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.
|
||||
THE PRESENTATION: Theory (the subject's own terms — symbols, formal objects, native vocabulary) or Application (a concrete worldly affair in which the proposition's structure appears in the wild). It dresses the situation; it never changes the rung or the cue distance.
|
||||
Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
|
||||
- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set — wishes, emphasis, context about themselves. Weigh it as preference, not instruction: take what serves the target and the parameters, ignore what conflicts with them, and never let it move a problem off its rung or out of its band. Its absence means nothing.
|
||||
- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as \`code\`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.
|
||||
- PREVIOUS PROBLEMS: problems the learner has already answered, with the chains they exercised. Do not rebuild one; keep your situation distant from theirs — cue distance counts previous problems' situations (Part I, "Cue distance").
|
||||
|
||||
OUTPUT — your entire deliverable is ONE submit_problem call:
|
||||
- chains: the chain ids this problem exercises — echo YOUR TARGET's ids VERBATIM (arg_… / n_…); never re-type a chain's premises or conclusion. The set's coverage is verified against the echo.
|
||||
- statement: the full problem formulation, world-facing; never names the propositions it tests (Part II: "Never announce the target").
|
||||
- solution: the model solution from Part II step 5 — every move, in order.
|
||||
- rubric: a brief free-form grading rubric — the bullet points of what actually matters when grading an answer against your solution.`;
|
||||
|
||||
const PARTITIONER_INSTRUCTIONS = `You are the partitioning stage of a learning application's practice system. Teaching installed a knowledge graph in the learner: propositions (nodes) and reasoning chains — the stored arguments that derive conclusions from premises. Practice problems will now be built to strengthen recall of those propositions across new situations. You do not write problems — you decide what each problem will be ABOUT, and how many problems there are. One generator agent then builds each problem from your partition, independently.
|
||||
|
||||
The graph arrives as THE CHAINS: every chain has its own id — an "arg_…" id for a derivation ("premises → conclusion"), or the proposition's own "n_…" id for a BARE proposition (a singular idea no argument uses — it is its own one-member target). A proposition legend gives each proposition id's name and full statement. You reference chains by ID ONLY — never re-type a chain's premises, never restate a chain as a line.
|
||||
|
||||
PARTITION DOCTRINE:
|
||||
- COVER EVERYTHING, EXACTLY ONCE: every chain appears in exactly ONE problem's assignment — a true partition. Nothing dropped: a dropped chain is an uncovered proposition, and an uncovered proposition is never practiced. Nothing doubled: variety across operations accumulates ACROSS problem sets, never by double-assigning within one.
|
||||
- SMALLEST SOUND SET: prefer fewer, well-loaded problems. But never force unrelated material together: a problem's chains must share a real target relationship (confusable with each other, feeding the same conclusion, forming a path, or clustered around one idea). If the only thing two chains share is the graph, they belong in different problems.
|
||||
- GROUP CONFUSABLES TOGETHER: propositions that apply to almost the same situations must land in the same problem — discrimination (choosing between near-identical situations) is a problem-engineering operation, and it is only possible when both confusables are in the room.
|
||||
- READ THE MIX OFF THE SHAPE: many singletons and few multi-premise chains suggest recall + discrimination problems; long premise chains suggest composition and path-finding. Your partition is where that judgment lives.
|
||||
- RESPECT THE PARAMETERS: the request names the ladder rung, the cue distance, and the presentation. Low rungs (0–2) mean many small assignments — single chains standing alone; high rungs (3–4) mean larger assignments — composition needs material to chain, path-finding needs distractor material to conceal the path among. Far cue distance makes grouping confusables together MORE important: the discrimination must travel into the far situation. Presentation does not shape your partition.
|
||||
- DO NOT DUPLICATE: previous problems the learner has already seen are listed with their chains. If an existing problem already covers a group of chains, re-partition so the new set differs in what it groups and how.
|
||||
- THE LEARNER'S NOTE (optional): free-form text the learner attached at creation — it may wish for grouping, count, or emphasis. Weigh it as preference; the partition laws above always win, and when the note conflicts with them, follow the laws silently.
|
||||
|
||||
OUTPUT — your entire deliverable is ONE submit_partition call: "partitions", a list of { chains: [chain ids] }, one entry per problem — the ids VERBATIM from THE CHAINS ("arg_…" for derivations, a bare proposition's own "n_…"), together containing every input chain exactly once. Never re-type a chain's propositions; never invent an id. No empty entries; no unknown ids; no problem without a real target relationship.`;
|
||||
|
||||
export const GRADER_INSTRUCTIONS = `You are the grading stage of a learning application's practice system. One practice problem — built to strengthen the learner's recall of specific propositions — has been answered. Your job is small and fast: an integer score and a one-or-two-sentence justification. You are not assessing a person; you are scoring a recall.
|
||||
|
||||
You receive: the problem's statement; its pre-written MODEL SOLUTION and GRADING RUBRIC (committed before the learner ever saw the problem — they are the reference, not you); the propositions (names + statements) the problem was built to exercise; and the learner's ANSWER — often dictated thinking-out-loud prose. Extract the reasoning from the prose; grade the substance, never the form. Do not demand the solution's exact wording — the rubric names what actually matters; nothing else is graded.
|
||||
|
||||
JUDGE AGAINST THE REFERENCE:
|
||||
- Work the rubric points: which are met, which are missed, which are half-met in the answer's own terms.
|
||||
- Where the answer diverges from the model solution, ask whether the divergence is a different-but-valid route (the rubric decides) or a wrong turn.
|
||||
|
||||
THE SCORE (one integer, 0–10):
|
||||
- 9–10: the targeted recall(s) happened — the answer meets the rubric's essentials. Peripheral gaps do not lower it.
|
||||
- 5–8: some of the target came to mind; the answer is materially incomplete or partly wrong.
|
||||
- 0–4: the targeted proposition(s) did not come to mind — the answer is empty or on the wrong track.
|
||||
|
||||
The reasoning is plain prose, one or two sentences: which rubric points were met or missed and, when something failed, WHICH proposition failed to come to mind. Mathematics is LaTeX — inline as \\(…\\), display as \\[…\\] (a bare $ is literal currency); no headers, no lists — minimal is the standard.
|
||||
|
||||
OUTPUT — your entire deliverable is ONE submit_grade call: "score" (integer 0–10) and "reasoning" (non-empty, brief).`;
|
||||
|
||||
// ─── schemas + fills ─────────────────────────────────────────────────────────
|
||||
|
||||
const partitionSchema = Type.Object(
|
||||
{
|
||||
partitions: Type.Array(
|
||||
Type.Object({
|
||||
chains: Type.Array(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Chain ids VERBATIM from THE CHAINS — arg_… for derivations, the proposition's own n_… for a bare proposition. Never re-type a chain's premises or conclusion; never invent an id.",
|
||||
}),
|
||||
{
|
||||
minItems: 1,
|
||||
description: "The ids this ONE problem will exercise — its target.",
|
||||
},
|
||||
),
|
||||
}),
|
||||
{
|
||||
minItems: 1,
|
||||
description: "One entry per problem.",
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The partition: one entry per problem; together they contain every input chain exactly once — a true partition.",
|
||||
},
|
||||
);
|
||||
type PartitionSubmit = Static<typeof partitionSchema>;
|
||||
|
||||
const problemSchema = Type.Object({
|
||||
chains: Type.Array(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description: "Chain ids — echo YOUR TARGET's ids verbatim (arg_… / n_…); never re-typed lines.",
|
||||
}),
|
||||
{ minItems: 1 },
|
||||
),
|
||||
statement: Type.String({ minLength: 1 }),
|
||||
solution: Type.String({ minLength: 1 }),
|
||||
rubric: Type.String({ minLength: 1 }),
|
||||
});
|
||||
type ProblemSubmit = Static<typeof problemSchema>;
|
||||
|
||||
const gradeSchema = Type.Object({
|
||||
score: Type.Integer({ minimum: 0, maximum: 10 }),
|
||||
reasoning: Type.String({ minLength: 1 }),
|
||||
});
|
||||
type GradeSubmit = Static<typeof gradeSchema>;
|
||||
|
||||
// ─── rendering (the agent-facing text) ───────────────────────────────────────
|
||||
|
||||
export type PropositionSnapshot = Record<string, { name: string; statement: string }>;
|
||||
|
||||
/** A previous problem the learner already answered — the dedup/situation
|
||||
* context generators and the partitioner both see. */
|
||||
export interface PreviousProblemRef {
|
||||
statement: string;
|
||||
chains: string[];
|
||||
}
|
||||
|
||||
export function renderPropositions(propositions: PropositionSnapshot): string {
|
||||
return Object.entries(propositions)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([id, p]) => `[${id}] ${p.name} — ${p.statement}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** One chain line for the agent-facing renderings — the chain's id LEADS
|
||||
* (owner fix, 2026-09-30 evening: the partitioner was told to reference
|
||||
* chain ids but the lines never showed one, so the model re-typed whole
|
||||
* rendered lines into the submit and everything failed validation). A
|
||||
* bare proposition repeats its own id with the no-premises marker —
|
||||
* mechanically teaching that its chain id IS its proposition id. */
|
||||
const chainLine = (c: Pick<ReasoningChain, "id" | "premises" | "conclusion">): string =>
|
||||
c.premises.length > 0
|
||||
? `[${c.id}] ${c.premises.join(", ")} → ${c.conclusion}`
|
||||
: `[${c.id}] ${c.conclusion} (no premises)`;
|
||||
|
||||
/** The proposition legend + chain list (ids LEAD every line — the
|
||||
* referenceable anchor, owner fix 2026-09-30) — the partitioner sees
|
||||
* ALL chains; the generator sees the full set too (renderChains) with
|
||||
* its target marked separately in the prompt. */
|
||||
export function renderChains(chains: ReasoningChain[], propositions: PropositionSnapshot): string {
|
||||
return `THE PROPOSITIONS\n${renderPropositions(propositions)}\n\nTHE CHAINS\n${chains.map(chainLine).join("\n")}`;
|
||||
}
|
||||
|
||||
export function renderParams(params: PracticeParams): string {
|
||||
const rung = LADDER_RUNGS[params.ladderRung];
|
||||
const cue = CUE_LEVELS[params.cueDistance];
|
||||
const pres = PRESENTATIONS[params.presentation];
|
||||
return [
|
||||
"THE PARAMETERS",
|
||||
`Ladder rung ${params.ladderRung} — ${rung.name}: ${rung.definition}`,
|
||||
`Cue distance ${params.cueDistance} — ${cue.name}: ${cue.definition}`,
|
||||
`Presentation: ${pres.name} — ${pres.definition}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderPreviousProblems(problems: PreviousProblemRef[]): string {
|
||||
if (problems.length === 0) return "None — this is the learner's first set.";
|
||||
return problems.map((p, i) => `${i + 1}. ${p.statement} (chains: ${p.chains.join(", ")})`).join("\n");
|
||||
}
|
||||
|
||||
// ─── the runners ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PracticeModels {
|
||||
/** Test seam: a scripted engine per stage — checked FIRST, ahead of the
|
||||
* engineFor chain (production wires no direct values). */
|
||||
partitioner?: PiEngine;
|
||||
problemGenerator?: PiEngine;
|
||||
grader?: PiEngine;
|
||||
/** Settings-resolved engine per role — read FRESH per stage call; the
|
||||
* BYOK chain may end empty → undefined → the refusal guard fires. */
|
||||
engineFor?: (role: ModelRole) => PiEngine | undefined;
|
||||
/** Key-presence gate (same injection discipline as extraction). */
|
||||
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
|
||||
}
|
||||
|
||||
/** One stage's money trail — the runner's own summary; the pipeline
|
||||
* (slice 4) folds these into its registry, NOT extraction's. */
|
||||
export interface PracticeStepLog {
|
||||
/** Provider-qualified model stamp ("provider:id"), first step's. */
|
||||
model: string | null;
|
||||
steps: CallUsage[];
|
||||
}
|
||||
|
||||
export interface PartitionerInput {
|
||||
chains: ReasoningChain[];
|
||||
propositions: PropositionSnapshot;
|
||||
params: PracticeParams;
|
||||
previousProblems: PreviousProblemRef[];
|
||||
/** The learner's creation note — optional; weighed, never obeyed. */
|
||||
note?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface PartitionerOutput {
|
||||
partitions: Array<{ chains: string[] }>;
|
||||
log: PracticeStepLog;
|
||||
}
|
||||
|
||||
export interface GeneratorInput {
|
||||
/** The partitioner's assignment for THIS problem — the echo law. */
|
||||
target: string[];
|
||||
/** The ENTIRE selection's chains (supporting material, minimal use). */
|
||||
fullChains: ReasoningChain[];
|
||||
propositions: PropositionSnapshot;
|
||||
params: PracticeParams;
|
||||
previousProblems: PreviousProblemRef[];
|
||||
/** The learner's creation note — optional; weighed, never obeyed. */
|
||||
note?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface GeneratedProblem {
|
||||
chains: string[];
|
||||
statement: string;
|
||||
solution: string;
|
||||
rubric: string;
|
||||
}
|
||||
export interface GeneratorOutput {
|
||||
problem: GeneratedProblem;
|
||||
log: PracticeStepLog;
|
||||
}
|
||||
|
||||
export interface GraderInput {
|
||||
problem: { statement: string; solution: string; rubric: string };
|
||||
/** The legend scoped to THIS problem's propositions. */
|
||||
propositions: PropositionSnapshot;
|
||||
answer: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface GraderOutput {
|
||||
score: number;
|
||||
reasoning: string;
|
||||
log: PracticeStepLog;
|
||||
}
|
||||
|
||||
/** The TRUE-PARTITION law (owner ruling 2026-09-29, prompt review): every
|
||||
* chain exactly once — nothing dropped, nothing doubled, nothing unknown.
|
||||
* Used at the submit seam; a violation goes back to the model as an error
|
||||
* tool result and retries. */
|
||||
function validatePartition(value: PartitionSubmit, chains: ReasoningChain[]): string | null {
|
||||
const known = new Set(chains.map((c) => c.id));
|
||||
const all: string[] = [];
|
||||
for (const p of value.partitions) all.push(...p.chains);
|
||||
const unknown = [...new Set(all.filter((id) => !known.has(id)))];
|
||||
if (unknown.length > 0) return `unknown chain ids: ${unknown.join(", ")}`;
|
||||
const dupes = [...new Set(all.filter((id, i) => all.indexOf(id) !== i))];
|
||||
if (dupes.length > 0) return `every chain belongs to exactly ONE problem — these appear more than once: ${dupes.join(", ")}`;
|
||||
const coverage = chainCoverage({ chains, problems: value.partitions });
|
||||
if (coverage.missing.length > 0) return `these chains are missing — nothing may be dropped: ${coverage.missing.join(", ")}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The ECHO law: the generator's submitted chains set EQUALS its assigned
|
||||
* target — the set's coverage is verified against the echo. */
|
||||
function validateProblem(value: ProblemSubmit, target: string[]): string | null {
|
||||
if (value.chains.length !== new Set(value.chains).size) {
|
||||
return "chains contains duplicates — echo your assigned target verbatim";
|
||||
}
|
||||
const want = [...new Set(target)].sort().join("\u0000");
|
||||
const got = [...new Set(value.chains)].sort().join("\u0000");
|
||||
if (got !== want) return `chains must echo your assigned target exactly: ${target.join(", ")}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The learner's creation note as a prompt block — present ONLY when a
|
||||
* note exists (owner, 2026-09-29: more learner control, nothing owed). */
|
||||
function learnerNoteBlock(note: string | undefined): string | null {
|
||||
return note
|
||||
? `THE LEARNER'S NOTE (free-form additional input — weigh it; you are not obliged to follow it)\n${note}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function createPracticeAgents(deps: PracticeModels) {
|
||||
const stageEngine = (role: ModelRole, override?: PiEngine): PiEngine | undefined =>
|
||||
override ?? deps.engineFor?.(role);
|
||||
|
||||
/** The per-need refusal guard (extraction's pattern): which slot tiers
|
||||
* are unset, and whether a configured stage's provider key is gone —
|
||||
* both refuse BEFORE any stage runs. */
|
||||
function refuseIfStagesUnready(): void {
|
||||
const stages: readonly [ModelRole, PiEngine | undefined][] = [
|
||||
["partitioner", stageEngine("partitioner", deps.partitioner)],
|
||||
["problemGenerator", stageEngine("problemGenerator", deps.problemGenerator)],
|
||||
["grader", stageEngine("grader", deps.grader)],
|
||||
];
|
||||
const missing = new Set<ModelSlotName>();
|
||||
for (const [role, engine] of stages) if (!engine) missing.add(ROLE_SLOT[role]);
|
||||
if (missing.size > 0) {
|
||||
console.info(`[practice] refused — no model configured for: ${[...missing].join(", ")}`);
|
||||
throw new ModelsNotConfiguredError([...missing]);
|
||||
}
|
||||
for (const [role, engine] of stages) {
|
||||
const refusal = deps.providerKeyMissing?.(engine!.model.provider, ROLE_SLOT[role]);
|
||||
if (refusal) {
|
||||
console.info(`[practice] refused — ${JSON.stringify(refusal)}`);
|
||||
throw new ProviderKeyMissingError(refusal.provider, refusal.slots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stepLogger(log: PracticeStepLog) {
|
||||
return (usage: Usage, model: string) => {
|
||||
if (!log.model) log.model = model;
|
||||
log.steps.push(callUsage(usage));
|
||||
};
|
||||
}
|
||||
|
||||
async function runPartitioner(input: PartitionerInput): Promise<PartitionerOutput> {
|
||||
const log: PracticeStepLog = { model: null, steps: [] };
|
||||
const prompt = [
|
||||
renderChains(input.chains, input.propositions),
|
||||
renderParams(input.params),
|
||||
learnerNoteBlock(input.note),
|
||||
"PREVIOUS PROBLEMS (already seen — do not duplicate)",
|
||||
renderPreviousProblems(input.previousProblems),
|
||||
]
|
||||
.filter((block): block is string => block !== null)
|
||||
.join("\n\n");
|
||||
const submitted = await runSubmitAgent({
|
||||
role: "partitioner",
|
||||
toolName: "submit_partition",
|
||||
toolDescription: "Submit the partition: one entry per problem, together containing every chain exactly once.",
|
||||
parameters: partitionSchema,
|
||||
fill: (raw) => raw,
|
||||
systemPrompt: PARTITIONER_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine: stageEngine("partitioner", deps.partitioner),
|
||||
signal: input.signal,
|
||||
onStep: stepLogger(log),
|
||||
neverSubmitted: "the partitioner never submitted a partition",
|
||||
validate: (value: PartitionSubmit) => validatePartition(value, input.chains),
|
||||
});
|
||||
return { partitions: submitted.partitions, log };
|
||||
}
|
||||
|
||||
async function runProblemGenerator(input: GeneratorInput): Promise<GeneratorOutput> {
|
||||
const log: PracticeStepLog = { model: null, steps: [] };
|
||||
const targetChains = input.fullChains.filter((c) => input.target.includes(c.id));
|
||||
const prompt = [
|
||||
`YOUR TARGET (the chains this problem is built to strengthen — reference these ids verbatim)\n${targetChains.map(chainLine).join("\n")}`,
|
||||
`THE FULL SET — propositions and chains of the entire selection (supporting material: minimal use, maximal focus on the target)\n${renderChains(input.fullChains, input.propositions)}`,
|
||||
renderParams(input.params),
|
||||
learnerNoteBlock(input.note),
|
||||
"PREVIOUS PROBLEMS (already answered — do not rebuild one; keep your situation distant from theirs)",
|
||||
renderPreviousProblems(input.previousProblems),
|
||||
]
|
||||
.filter((block): block is string => block !== null)
|
||||
.join("\n\n");
|
||||
const problem = await runSubmitAgent({
|
||||
role: "problemGenerator",
|
||||
toolName: "submit_problem",
|
||||
toolDescription: "Submit ONE problem: chains (echo your target verbatim), statement, solution, rubric.",
|
||||
parameters: problemSchema,
|
||||
fill: (raw) => raw,
|
||||
systemPrompt: GENERATOR_DOCTRINE,
|
||||
prompt,
|
||||
engine: stageEngine("problemGenerator", deps.problemGenerator),
|
||||
signal: input.signal,
|
||||
onStep: stepLogger(log),
|
||||
neverSubmitted: "the problem generator never submitted a problem",
|
||||
validate: (value: ProblemSubmit) => validateProblem(value, input.target),
|
||||
});
|
||||
return { problem, log };
|
||||
}
|
||||
|
||||
async function runGrader(input: GraderInput): Promise<GraderOutput> {
|
||||
const log: PracticeStepLog = { model: null, steps: [] };
|
||||
const prompt = [
|
||||
`THE PROBLEM\n${input.problem.statement}`,
|
||||
`THE PROPOSITIONS IT EXERCISES\n${renderPropositions(input.propositions)}`,
|
||||
`THE MODEL SOLUTION\n${input.problem.solution}`,
|
||||
`THE GRADING RUBRIC\n${input.problem.rubric}`,
|
||||
`THE LEARNER'S ANSWER\n${input.answer}`,
|
||||
].join("\n\n");
|
||||
const grade = await runSubmitAgent({
|
||||
role: "grader",
|
||||
toolName: "submit_grade",
|
||||
toolDescription: "Submit the grade: an integer score 0-10 and brief non-empty reasoning.",
|
||||
parameters: gradeSchema,
|
||||
fill: (raw) => raw,
|
||||
systemPrompt: GRADER_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine: stageEngine("grader", deps.grader),
|
||||
signal: input.signal,
|
||||
onStep: stepLogger(log),
|
||||
neverSubmitted: "the grader never submitted a grade",
|
||||
});
|
||||
return { score: grade.score, reasoning: grade.reasoning, log };
|
||||
}
|
||||
|
||||
return { refuseIfStagesUnready, runPartitioner, runProblemGenerator, runGrader };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* runSubmitAgent — the shared single-submit agent seam (moved verbatim from
|
||||
* extraction.ts, practice slice 3, 2026-09-29): one TypeBox-validated submit
|
||||
* extraction.ts): one TypeBox-validated submit
|
||||
* call is the whole deliverable (a pi agent purely so the model can retry a
|
||||
* schema-invalid call). The submitted value is captured from the execute
|
||||
* seam, which only fires on a schema-valid call — the ??-fill (the old zod
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
||||
// chunk both agents compose (prompts/shared/20-question-tools.md and the
|
||||
// teacher's question fragments), reversing the 2026-09-27 placement now
|
||||
// that the question family has grown (predict/check roles, future
|
||||
// practice variants): duplicates stay honest by sharing SCHEMA ATOMS
|
||||
// quiz variants): duplicates stay honest by sharing SCHEMA ATOMS
|
||||
// (question-core) + PROMPT CHUNKS, and each tool declares only its
|
||||
// mechanical delta.
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Selection-facing chain views — pure + browser-safe (the shell's chain
|
||||
* table consumes these; the barrel exports them). Born in the practice
|
||||
* contracts (2026-09-29), orphaned onto their own module when the batch
|
||||
* practice module was replaced by practice sessions (2026-10-04).
|
||||
*/
|
||||
|
||||
/** One reasoning chain — a selection-facing VIEW of stored follows-edges:
|
||||
* all edges into one conclusion, as premises → that conclusion. Singleton
|
||||
* chains (premises: []) carry edge-less nodes. */
|
||||
export interface ReasoningChain {
|
||||
/** The chain's identity — the CONCLUSION node's id (chains group by
|
||||
* conclusion, so the two are one). */
|
||||
id: string;
|
||||
/** Premise node ids — every follows-edge source into the conclusion,
|
||||
* sorted. Empty = a singleton chain (an edge-less node). */
|
||||
premises: string[];
|
||||
/** The conclusion node id (=== id; mirrored so renderings read
|
||||
* "premises → conclusion" without indirection). */
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
/** The stored chains filtered to a selection (chains-primary, owner
|
||||
* 2026-09-30): a chain rides along iff its conclusion AND every premise
|
||||
* are selected; a node in no surviving chain is a singleton chain
|
||||
* (premises: []). Premise-only nodes whose chain was excluded are
|
||||
* singletons — the same inclusion law as the pre-chains edge grouping.
|
||||
* Every stored chain keeps its OWN id (arg_…) — two independent
|
||||
* arguments for one conclusion stay distinct. Deterministic: chains
|
||||
* sorted by id, premises sorted. */
|
||||
export function chainsForSelection(
|
||||
nodes: ReadonlyArray<Pick<{ id: string }, "id">>,
|
||||
chains: ReadonlyArray<Pick<{ id: string; premises: string[]; conclusion: string }, "id" | "premises" | "conclusion">>,
|
||||
): ReasoningChain[] {
|
||||
const selected = new Set(nodes.map((n) => n.id));
|
||||
const chained = new Set<string>();
|
||||
const included: ReasoningChain[] = [];
|
||||
for (const chain of chains) {
|
||||
if (!selected.has(chain.conclusion)) continue;
|
||||
if (!chain.premises.every((premise) => selected.has(premise))) continue;
|
||||
included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion });
|
||||
chained.add(chain.conclusion);
|
||||
for (const premise of chain.premises) chained.add(premise);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (!chained.has(node.id)) {
|
||||
included.push({ id: node.id, premises: [], conclusion: node.id });
|
||||
}
|
||||
}
|
||||
return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
}
|
||||
|
||||
/** A chain as one world-facing line — the propositions' NAMES joined
|
||||
* by the derivation arrow: "Premise one, Premise two → Conclusion"; a
|
||||
* singleton shows its "(no premises)" marker. Pure + browser-safe.
|
||||
* Unknown ids fall back to the raw id (honest, never guessed).
|
||||
* Premises sort by id — the same determinism law as chainsForSelection. */
|
||||
export function formatChainLine(
|
||||
chain: Pick<ReasoningChain, "premises" | "conclusion">,
|
||||
propositions: Readonly<Record<string, { name: string }>>,
|
||||
): string {
|
||||
const nameOf = (id: string) => propositions[id]?.name ?? id;
|
||||
const premises = [...chain.premises].sort().map(nameOf).join(", ");
|
||||
return premises !== ""
|
||||
? `${premises} → ${nameOf(chain.conclusion)}`
|
||||
: `${nameOf(chain.conclusion)} (no premises)`;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Split by domain (src/core/contracts/): plan.ts (the session plan tree),
|
||||
* graph.ts (the global knowledge graph + its extraction pipeline), chat.ts
|
||||
* (session records + UI extension points), jobs.ts (the async sub-agent
|
||||
* protocol), practice.ts (the practice-problem system). Each domain file is
|
||||
* protocol), Each domain file is
|
||||
* owned outright by its stream; schema changes to a domain you don't own go
|
||||
* through master. This barrel keeps every existing import path
|
||||
* ("@/src/core" and "./contracts.ts") unchanged.
|
||||
@@ -16,4 +16,3 @@ export * from "./contracts/chat.ts";
|
||||
export * from "./contracts/jobs.ts";
|
||||
export * from "./contracts/probe.ts";
|
||||
export * from "./contracts/models.ts";
|
||||
export * from "./contracts/practice.ts";
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* message-granular; extraction is free-form and post-hoc, fully decoupled
|
||||
* from the session's plan tree. The graph is an extraction of the reasoning
|
||||
* chains from all teaching sessions (and imported notes) — NOTHING else:
|
||||
* no quiz outcomes, no understanding assessments (2026-09-27 cleanup —
|
||||
* understanding becomes practice-module territory). Anything not
|
||||
* no quiz outcomes, no understanding assessments (2026-09-27 cleanup).
|
||||
* Anything not
|
||||
* yet decided (the state model, strength derivation, selection strategies) is deliberately NOT here — transitional
|
||||
* stubs carry the renderer until those land.
|
||||
*
|
||||
@@ -289,26 +289,6 @@ export interface Cluster {
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/** One birth-session cluster as the practice picker consumes it (owner
|
||||
* ruling 2026-09-29: problem sets take NODES — the UI resolves a picked
|
||||
* cluster to its member ids). EVERY birth-session group regardless of
|
||||
* size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing
|
||||
* rule — practice availability must not inherit a visual threshold);
|
||||
* `label` is null when the naming record is gone — honest, never
|
||||
* guessed (the graph view renders those unlabeled). */
|
||||
export interface GraphClusterGroup {
|
||||
/** The birth session's id — a chatId or a notes source id. */
|
||||
key: string;
|
||||
/** What kind of record names the group. */
|
||||
kind: "chat" | "notes";
|
||||
/** Session title / source file path; null when the record is gone. */
|
||||
label: string | null;
|
||||
/** Member node ids, sorted (the partition's members). */
|
||||
nodeIds: string[];
|
||||
/** === nodeIds.length, denormalized for the picker row. */
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export interface GraphSnapshot {
|
||||
/** Proposition nodes + the DERIVED implication nodes (one per chain,
|
||||
* id = chain id) — what the canvas renders. */
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Practice-problem contracts — the practice arc (feat/practice-problems,
|
||||
* plan locked 2026-09-29).
|
||||
*
|
||||
* PURPOSE (owner, 2026-09-29): strengthening, NOT assessment — maximize
|
||||
* strengthening per unit of mental effort. Teaching sessions install the
|
||||
* graph; practice widens the set of situations in which held propositions
|
||||
* come to mind. The old vision doc's exam machinery (assessment records,
|
||||
* understanding writes) is PARKED: grading is feedback-only and never
|
||||
* touches the graph.
|
||||
*
|
||||
* THE CHAIN MODEL (owner, 2026-09-29 — two rulings):
|
||||
* - Chains group by CONCLUSION: every follows-edge into a node forms ONE
|
||||
* chain (its premises → it). A conclusion of one chain can be a premise
|
||||
* of later chains; nodes may repeat across chains. Edge-less nodes are
|
||||
* singleton chains.
|
||||
* - FOLLOWS ONLY: supports edges are invisible to practice ("no edges")
|
||||
* — they neither chain nor cover.
|
||||
* The translation is a bijection: the union of premises→conclusion pairs
|
||||
* reconstructs exactly the selection's follows-edges (core/practice.ts).
|
||||
*
|
||||
* GENERATION is two-stage: a partitioner agent partitions the chains into
|
||||
* the smallest covering set of problems (dedup-aware, confusables grouped);
|
||||
* then ONE generator agent per problem builds it (the problem-engineering
|
||||
* framework as its doctrine). Numbered problems appear in the UI the
|
||||
* moment the partition lands — loading until their generator finishes —
|
||||
* and NEVER show which propositions they test. Each problem commits to its
|
||||
* full formulation, model solution, and grading rubric up front
|
||||
* (commit-before-pose, the sycophancy mitigation). GRADING is one
|
||||
* background job per problem, against the pre-written solution + rubric,
|
||||
* feedback-only.
|
||||
*
|
||||
* Part of the domain-split contracts (src/core/contracts/): this file is
|
||||
* owned by the practice stream. Pure types + protocol docs only.
|
||||
*/
|
||||
|
||||
/** One reasoning chain — the practice module's agent-facing unit: all
|
||||
* follows-edges into one conclusion, as premises → that conclusion.
|
||||
* Singleton chains (premises: []) carry edge-less nodes. */
|
||||
export interface ReasoningChain {
|
||||
/** The chain's identity — the CONCLUSION node's id (chains group by
|
||||
* conclusion, so the two are one). */
|
||||
id: string;
|
||||
/** Premise node ids — every follows-edge source into the conclusion,
|
||||
* sorted. Empty = a singleton chain (an edge-less node). */
|
||||
premises: string[];
|
||||
/** The conclusion node id (=== id; mirrored so agent-facing renderings
|
||||
* read "premises → conclusion" without indirection). */
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
/** The main axis, discretized (owner, 2026-09-29 — named levels, never
|
||||
* a number to interpolate; definitions verbatim from the ladder
|
||||
* doctrine): */
|
||||
export type LadderRung = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
/** Cue distance from every situation the learner has already met — the
|
||||
* lesson's own examples AND every previous problem's situation
|
||||
* (definitions owner-approved 2026-09-29): */
|
||||
export type CueDistance = 0 | 1 | 2;
|
||||
|
||||
/** How the situation is dressed — never changes the rung or the cue
|
||||
* distance: */
|
||||
export type Presentation = "theory" | "application";
|
||||
|
||||
/** The three settings every problem set is configured with (owner,
|
||||
* 2026-09-29) — each a named level, invoked as that level exactly:
|
||||
*
|
||||
* THE LADDER (the main axis, bottom to top):
|
||||
* 0 Worked example — the solution is shown; nothing is demanded. An
|
||||
* installation object; nothing yet to recall.
|
||||
* 1 Completion — the path is shown with a gap; the learner supplies
|
||||
* one step. What to recall is nearly announced.
|
||||
* 2 Direct exercise — one step, no hint, but the situation makes the
|
||||
* applicable proposition unmistakable. Trains execution, not choosing.
|
||||
* 3 Composition — several steps, chained; the path is narrow but must
|
||||
* be held and run.
|
||||
* 4 Path-finding — several steps, the path concealed, distractors
|
||||
* present, the statement suggesting wrong methods. The competition
|
||||
* shape.
|
||||
*
|
||||
* THE CUE DISTANCE:
|
||||
* 0 Lesson context — the textbook/toy-example shape in the teaching's
|
||||
* own framing.
|
||||
* 1 Adjacent context — a genuinely new situation built from the
|
||||
* teaching's own world — same domain, different setup/packaging.
|
||||
* 2 Far context — as far from the typical cue as recognition allows,
|
||||
* the edge of the recognizable band; the proposition must still
|
||||
* genuinely apply, and the learner must be able to find it by looking.
|
||||
*
|
||||
* THE PRESENTATION:
|
||||
* theory — the subject's own terms — symbols, formal objects, native
|
||||
* vocabulary.
|
||||
* application — a concrete worldly affair — a case, episode, system,
|
||||
* or story — in which the proposition's structure appears in the wild. */
|
||||
export interface PracticeParams {
|
||||
/** Which class of problem to build — exactly that rung. */
|
||||
ladderRung: LadderRung;
|
||||
/** How far the situation sits from the cues already met. */
|
||||
cueDistance: CueDistance;
|
||||
/** How the situation is dressed. */
|
||||
presentation: Presentation;
|
||||
}
|
||||
|
||||
/** Where a set stands in the two-stage run. The set is PERSISTED at
|
||||
* "partitioning" (before the partitioner runs), its problem stubs land
|
||||
* (still "generating") the moment the partition submits, and it flips to
|
||||
* "ready" when every generator has settled — errored problems included
|
||||
* (per-problem failure isolation: they carry their own status). */
|
||||
export type ProblemSetStatus = "partitioning" | "generating" | "ready" | "error";
|
||||
|
||||
/** One problem's own lifecycle — a stub from the partition until its
|
||||
* generator submits. */
|
||||
export type ProblemStatus = "generating" | "ready" | "error";
|
||||
|
||||
/** The grading job's deliverable (owner, 2026-09-29 — one attempt per
|
||||
* problem; the grader scores, it does not coach in prose): an integer
|
||||
* score against the pre-written rubric plus a minimal justification.
|
||||
* Nothing derives from it — no understanding writes. */
|
||||
export interface ProblemGrade {
|
||||
/** 0–10, integer. */
|
||||
score: number;
|
||||
/** Why this score — one or two sentences: which rubric points were
|
||||
* met or missed, and which proposition failed to come to mind. */
|
||||
reasoning: string;
|
||||
gradedAt: string;
|
||||
}
|
||||
|
||||
/** One practice problem — built by ONE generator agent from its assigned
|
||||
* chains, fully committed before the learner ever sees it. No name field
|
||||
* (owner, 2026-09-29): problems are numbered by position, nothing else. */
|
||||
export interface Problem {
|
||||
/** "pp_" + ts36 + rand — store-assigned. */
|
||||
id: string;
|
||||
/** Chain ids (ReasoningChain.id — the conclusion node ids) this problem
|
||||
* exercises. The coverage law: the union over all problems must equal
|
||||
* the set's chains. */
|
||||
chains: string[];
|
||||
/** The full problem formulation — world-facing; never names the
|
||||
* propositions it tests. */
|
||||
statement: string;
|
||||
/** The pre-written model solution (commit-before-pose). */
|
||||
solution: string;
|
||||
/** The brief free-form grading rubric — bullet points of what actually
|
||||
* matters. */
|
||||
rubric: string;
|
||||
status: ProblemStatus;
|
||||
/** Set when this problem's generation failed (the set completes
|
||||
* around it). */
|
||||
error?: string;
|
||||
/** The learner's answer, once submitted (dictation-first — thinking
|
||||
* out loud is the primary answering mode). */
|
||||
answer?: string;
|
||||
/** The grader's score + reasoning, once it lands. */
|
||||
grade?: ProblemGrade;
|
||||
}
|
||||
|
||||
/** One problem set — the persisted unit (one JSON document per set). */
|
||||
export interface ProblemSet {
|
||||
/** "ps_" + ts36 + rand — store-assigned. */
|
||||
id: string;
|
||||
/** Display label — the picked cluster's label, or the dated
|
||||
* default. */
|
||||
title: string;
|
||||
createdAt: string;
|
||||
/** Bumped on every store write — the live-run heartbeat the boot
|
||||
* sweep's grace window reads (a live run saves at every phase or
|
||||
* problem transition, so it is structurally never 120s stale).
|
||||
* Absent on legacy rows (validator tolerates; readers fall back
|
||||
* to createdAt). */
|
||||
updatedAt?: string;
|
||||
params: PracticeParams;
|
||||
/** The selected node ids — the raw selection record. The chain
|
||||
* 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;
|
||||
/** The learner's free-form note from the creation dialog (owner,
|
||||
* 2026-09-29: more learner control, no other purpose). Carried to the
|
||||
* partitioner and every generator as ADDITIONAL INPUT — weighed as
|
||||
* preference, never an obligation, never overriding the laws. Trimmed,
|
||||
* capped (2000 chars at the API); persisted so the set stays a
|
||||
* self-contained document of what was asked for. */
|
||||
note?: string;
|
||||
/** Problem stubs appear here the moment the partitioner submits —
|
||||
* numbered by position, "generating", propositions invisible. */
|
||||
problems: Problem[];
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One bus, every layer: server emits turn lifecycle here, the store
|
||||
* adapters emit job/chat mutations, the agent layer's background
|
||||
* registries (extraction, practice) emit activity changes — and
|
||||
* registries (extraction) emit activity changes — and
|
||||
* GET /api/events turns every emission into an SSE frame for any open
|
||||
* window. The events carry the FACT, never the data: consumers fetch
|
||||
* truth on receipt (push the poke, pull the data), so payloads stay
|
||||
@@ -34,7 +34,7 @@ export interface ServerEvents {
|
||||
/** A job record changed (dispatch, progress, settle, abort, recovery).
|
||||
* chatId when known — consumers that own one chat filter on it. */
|
||||
"jobs-changed": { chatId?: string };
|
||||
/** An extraction or practice run changed phase (activity dock). */
|
||||
/** An extraction run changed phase (activity dock). */
|
||||
"activity-changed": Record<string, never>;
|
||||
/** The session list changed (create, delete, title). */
|
||||
"chats-changed": Record<string, never>;
|
||||
|
||||
+7
-4
@@ -48,7 +48,8 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||
import type { CoverageEntry,
|
||||
} from "./contracts/graph.ts";
|
||||
import type {
|
||||
AuditVerdicts,
|
||||
ExtractedChain,
|
||||
@@ -62,8 +63,8 @@ import type {
|
||||
GraphSnapshot,
|
||||
} from "./contracts.ts";
|
||||
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||
import { formatChainLine } from "./chains.ts";
|
||||
import { attachImpToCommunities, birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
||||
import { formatChainLine } from "./practice.ts";
|
||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||
|
||||
/** The storage ports every graph operation needs, passed explicitly. */
|
||||
@@ -500,7 +501,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
const graph = getGraph(deps.graphStore);
|
||||
// The canvas-facing lossy image: propositions + derived implication
|
||||
// nodes, untyped arrows. `chains` (the stored truth) ride along for
|
||||
// the chain table and the practice pipeline — they read the truth,
|
||||
// the chain table — it reads the truth, never the derived fan-out,
|
||||
// never the derived fan-out.
|
||||
const view = deriveView(graph);
|
||||
const nodes = view.nodes;
|
||||
@@ -657,7 +658,7 @@ export function graphSessionIndex(deps: GraphDeps): GraphSessionEntry[] {
|
||||
}
|
||||
|
||||
/** One birth proposition of one session — WHAT WAS COVERED there (2026-09-27
|
||||
* cleanup: no understanding levels; assessment is practice-module
|
||||
* cleanup: no understanding levels; assessment is
|
||||
* territory and nothing writes it until that module exists). */
|
||||
export interface GraphSessionProposition {
|
||||
nodeId: string;
|
||||
@@ -676,3 +677,5 @@ export function graphSessionPropositions(deps: GraphDeps, sessionId: string): Gr
|
||||
|
||||
// Re-export: the contract owns the shape; legacy importers still reach it.
|
||||
export type { EmissionStats };
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -17,6 +17,7 @@
|
||||
|
||||
export * from "./contracts.ts";
|
||||
export * from "./ports.ts";
|
||||
export * from "./chains.ts";
|
||||
export * from "./render-settings.ts";
|
||||
|
||||
export * from "./question-core.ts";
|
||||
@@ -35,7 +36,6 @@ export * from "./phases.ts";
|
||||
export * from "./pending.ts";
|
||||
export * from "./prompt-segments.ts";
|
||||
export * from "./math-delims.ts";
|
||||
export * from "./practice.ts";
|
||||
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
|
||||
// and the input-events contract — all
|
||||
// pure logic, browser-safe.
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
ExtractionRunUsage,
|
||||
PlanTree,
|
||||
Position,
|
||||
ProblemSet,
|
||||
CoverageEntry,
|
||||
SessionCheckpoint,
|
||||
TurnUsage,
|
||||
@@ -157,33 +156,6 @@ 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) ──
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Practice-module core — the chain bijection + coverage (slice 1 of the
|
||||
* 2026-09-29 plan). Pure, no I/O.
|
||||
*
|
||||
* chainsForSelection(): the stored CHAINS (chains-primary, owner
|
||||
* 2026-09-30) filtered to a selection — a chain rides along iff ALL of
|
||||
* its members are selected; nodes in no surviving chain become singleton
|
||||
* chains. formatChainLine(): one world-facing line per chain.
|
||||
* chainCoverage(): the set's completeness law — the union of the
|
||||
* problems' chains must equal the set's chains; the partitioner's submit
|
||||
* is validated against it.
|
||||
*/
|
||||
import type { CueDistance, GraphChain, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts";
|
||||
|
||||
/** The named levels (owner-approved 2026-09-29) — definitions verbatim
|
||||
* from the ladder doctrine / the contract's tables. The UI's stepped
|
||||
* controls, the exam header's levels line, and the agents' prompt
|
||||
* rendering all speak these names — never a bare number. */
|
||||
export const LADDER_RUNGS: Record<LadderRung, { name: string; definition: string }> = {
|
||||
0: { name: "Worked example", definition: "the solution is shown; nothing is demanded. An installation object; nothing yet to recall." },
|
||||
1: { name: "Completion", definition: "the path is shown with a gap; the learner supplies one step. What to recall is nearly announced." },
|
||||
2: { name: "Direct exercise", definition: "one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing." },
|
||||
3: { name: "Composition", definition: "several steps, chained; the path is narrow but must be held and run." },
|
||||
4: { name: "Path-finding", definition: "several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape." },
|
||||
};
|
||||
|
||||
export const CUE_LEVELS: Record<CueDistance, { name: string; definition: string }> = {
|
||||
0: { name: "Lesson context", definition: "the textbook/toy-example shape in the teaching's own framing." },
|
||||
1: { name: "Adjacent context", definition: "a genuinely new situation built from the teaching's own world — same domain, different setup/packaging." },
|
||||
2: { name: "Far context", definition: "as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking." },
|
||||
};
|
||||
|
||||
export const PRESENTATIONS: Record<Presentation, { name: string; definition: string }> = {
|
||||
theory: { name: "Theory", definition: "the subject's own terms — symbols, formal objects, native vocabulary." },
|
||||
application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
|
||||
};
|
||||
|
||||
/** The stored chains filtered to a selection (chains-primary, owner
|
||||
* 2026-09-30): a chain rides along iff its conclusion AND every premise
|
||||
* are selected; a node in no surviving chain is a singleton chain
|
||||
* (premises: []). Premise-only nodes whose chain was excluded are
|
||||
* singletons — the same inclusion law as the pre-chains edge grouping.
|
||||
* Every stored chain keeps its OWN id (arg_…) — two independent
|
||||
* arguments for one conclusion stay distinct. Deterministic: chains
|
||||
* sorted by id, premises sorted. */
|
||||
export function chainsForSelection(
|
||||
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
|
||||
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
|
||||
): ReasoningChain[] {
|
||||
const selected = new Set(nodes.map((n) => n.id));
|
||||
const chained = new Set<string>();
|
||||
const included: ReasoningChain[] = [];
|
||||
for (const chain of chains) {
|
||||
if (!selected.has(chain.conclusion)) continue;
|
||||
if (!chain.premises.every((premise) => selected.has(premise))) continue;
|
||||
included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion });
|
||||
chained.add(chain.conclusion);
|
||||
for (const premise of chain.premises) chained.add(premise);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (!chained.has(node.id)) {
|
||||
included.push({ id: node.id, premises: [], conclusion: node.id });
|
||||
}
|
||||
}
|
||||
return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
}
|
||||
|
||||
/** A chain as one world-facing line — the propositions' NAMES joined
|
||||
* by the derivation arrow: "Premise one, Premise two → Conclusion"; a
|
||||
* singleton shows its "(no premises)" marker. Pure + browser-safe:
|
||||
* the shell (chain table, derived previews) cannot import the agent
|
||||
* module — the level tables set this precedent. Unknown ids fall back
|
||||
* to the raw id (honest, never guessed). Premises sort by id — the
|
||||
* same determinism law as chainsForSelection. */
|
||||
export function formatChainLine(
|
||||
chain: Pick<ReasoningChain, "premises" | "conclusion">,
|
||||
propositions: Readonly<Record<string, { name: string }>>,
|
||||
): string {
|
||||
const nameOf = (id: string) => propositions[id]?.name ?? id;
|
||||
const premises = [...chain.premises].sort().map(nameOf).join(", ");
|
||||
return premises !== ""
|
||||
? `${premises} → ${nameOf(chain.conclusion)}`
|
||||
: `${nameOf(chain.conclusion)} (no premises)`;
|
||||
}
|
||||
|
||||
/** chainCoverage()'s report. `missing` fails the partition; `duplicated`
|
||||
* is informational — a chain MAY legitimately be exercised by several
|
||||
* problems (the operations make that sound). */
|
||||
export interface ChainCoverageReport {
|
||||
/** Chain ids no problem exercises — a partition leaving any is invalid. */
|
||||
missing: string[];
|
||||
/** Chain ids exercised by more than one problem (allowed). */
|
||||
duplicated: string[];
|
||||
}
|
||||
|
||||
/** The set's completeness law: every chain of the input must be exercised
|
||||
* by the union of the problems' chains. Pure — the partitioner's submit
|
||||
* validation (slice 3) and the tests call this. */
|
||||
export function chainCoverage(
|
||||
set: {
|
||||
chains: ReadonlyArray<Pick<ReasoningChain, "id">>;
|
||||
problems: ReadonlyArray<Pick<Problem, "chains">>;
|
||||
},
|
||||
): ChainCoverageReport {
|
||||
const counts = new Map<string, number>();
|
||||
for (const problem of set.problems) {
|
||||
for (const chainId of problem.chains) {
|
||||
counts.set(chainId, (counts.get(chainId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const missing: string[] = [];
|
||||
const duplicated: string[] = [];
|
||||
for (const chain of set.chains) {
|
||||
const count = counts.get(chain.id) ?? 0;
|
||||
if (count === 0) missing.push(chain.id);
|
||||
else if (count > 1) duplicated.push(chain.id);
|
||||
}
|
||||
return { missing: missing.sort(), duplicated: duplicated.sort() };
|
||||
}
|
||||
@@ -46,9 +46,6 @@ export const BACKGROUND_MODEL_ROLES = [
|
||||
"visualMaker",
|
||||
"extractor",
|
||||
"auditor",
|
||||
"partitioner",
|
||||
"problemGenerator",
|
||||
"grader",
|
||||
] as const;
|
||||
|
||||
export type BackgroundModelRole = (typeof BACKGROUND_MODEL_ROLES)[number];
|
||||
@@ -229,12 +226,6 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
|
||||
auditor: "cheap",
|
||||
visualMaker: "vision",
|
||||
pdfReader: "vision",
|
||||
// The practice trio (2026-09-29): problem engineering is the product's
|
||||
// quality-critical component — strong tier for partitioner/generator/
|
||||
// grader alike (owner-approved plan default; one edit away from cheap).
|
||||
partitioner: "strong",
|
||||
problemGenerator: "strong",
|
||||
grader: "strong",
|
||||
};
|
||||
|
||||
/** General settings — app-level behavior. Version DISPLAY and plans login
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type Tea
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import { createPracticePipeline } from "../agent/subagents/practice-pipeline.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import {
|
||||
deliverJobResult,
|
||||
@@ -104,22 +103,6 @@ export const extraction = createExtractionPipeline({
|
||||
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
|
||||
});
|
||||
|
||||
/** The app's ONE practice pipeline instance — the API routes reach it
|
||||
* through the server barrel / service, same discipline as extraction. */
|
||||
export const practice = createPracticePipeline({
|
||||
graphStore: stores.graphStore,
|
||||
practiceStore: stores.practiceStore,
|
||||
// BYOK (owner, 2026-09-22): the practice stages resolve the chain —
|
||||
// role override → slot → NOTHING — read FRESH per stage call; an empty
|
||||
// chain flows through as undefined and the pipeline refuses the run
|
||||
// with the typed ModelsNotConfiguredError. Direct test values still win.
|
||||
engineFor: (role) =>
|
||||
resolveEngine(role),
|
||||
// Key-presence gate: same check extraction gets — a stage engine whose
|
||||
// provider key is gone refuses BEFORE any set is created.
|
||||
providerKeyMissing: (provider, tier) =>
|
||||
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
|
||||
});
|
||||
|
||||
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
||||
// delivers its steers through THIS composition root (one event: the steer
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* THE NOTIFICATION CHANNEL (push-architecture slice 1): GET /api/events is
|
||||
* an SSE response over the core event bus — one connection per open
|
||||
* window, held open until the view goes away, fed by every emitter
|
||||
* (turn lifecycle, job/chat stores, extraction/practice registries).
|
||||
* (turn lifecycle, job/chat stores, extraction registry).
|
||||
*
|
||||
* NOTIFY → FETCH (the architecture's whole trick): frames carry only the
|
||||
* event type + a tiny payload; consumers respond by fetching truth
|
||||
|
||||
+1
-24
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
|
||||
import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
|
||||
import type { GraphSnapshot } from "../core/contracts.ts";
|
||||
import type { GraphCostSummary } from "../core/cost-summary.ts";
|
||||
import { graphCostSummary } from "../core/cost-summary.ts";
|
||||
import { graphSnapshot } from "../core/graph.ts";
|
||||
@@ -22,29 +22,6 @@ export function graphView(): GraphSnapshot {
|
||||
return graphSnapshot(stores);
|
||||
}
|
||||
|
||||
/** GET /api/graph/clusters — the birth-session groups, resolved to node
|
||||
* ids for the practice picker (owner ruling 2026-09-29: cluster→nodeIds
|
||||
* resolves in the UI; the API stays graph-domain). EVERY group is
|
||||
* listed regardless of size (owner ruling, same evening:
|
||||
* MIN_CLUSTER_SIZE is a hull-DRAWING rule — practice availability must
|
||||
* not inherit a visual threshold), with the partition's own ordering
|
||||
* (size desc, ties by id asc). The label join mirrors the snapshot's:
|
||||
* chat title / notes file path, null when the naming record is gone —
|
||||
* honest, never guessed. A corrupt/empty graph is an empty list. */
|
||||
export function graphClusters(): GraphClusterGroup[] {
|
||||
const snap = graphSnapshot(stores);
|
||||
// PROPOSITIONS ONLY: snap.nodes is the canvas image (imp nodes ride
|
||||
// it); the picker offers propositions — an arg_ id in nodeIds would
|
||||
// silently vanish inside chainsForSelection and lie about nodeCount.
|
||||
return birthSessionPartition(snap.propositions).map((group) => ({
|
||||
key: group.id,
|
||||
kind: group.id in snap.sessions ? "chat" : "notes",
|
||||
label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null,
|
||||
nodeIds: group.members,
|
||||
nodeCount: group.members.length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost
|
||||
* pill): fold every usage channel into stage totals + recent runs. A pure
|
||||
* read over the chat records (chat-front-door runs), the notes sources
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
export * from "./chat-handler.ts";
|
||||
export * from "./events-stream.ts";
|
||||
export * from "./chats.ts";
|
||||
export * from "./practice.ts";
|
||||
export * from "./graph.ts";
|
||||
export * from "./commit-positions.ts";
|
||||
export * from "./jobs.ts";
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
|
||||
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
|
||||
import { practiceStatuses, type PracticePhase } from "../agent/subagents/practice-pipeline.ts";
|
||||
import { chatStore } from "../store/chats.ts";
|
||||
import { graphRevision } from "./graph.ts";
|
||||
import { catalogContextLimits } from "./model-catalog.ts";
|
||||
@@ -28,7 +27,6 @@ import { listJobs } from "./jobs.ts";
|
||||
import type { JobRecord } from "../core/contracts.ts";
|
||||
|
||||
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
||||
export { practiceStatuses, type PracticePhase, type RunningPractice } from "../agent/subagents/practice-pipeline.ts";
|
||||
export { chatStore } from "../store/chats.ts";
|
||||
|
||||
/** A running sub-agent job, joined with its chat's title for the dock.
|
||||
@@ -56,21 +54,6 @@ export interface ActivityExtraction {
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** A live practice ROW in the dock's feed — per-problem, matching the
|
||||
* extraction per-chunk pattern (owner ruling, 2026-09-29). id is the
|
||||
* ROW KEY (`${setId}:partition` | `${setId}:gen:${pid}` |
|
||||
* `${setId}:grade:${pid}`) — the dock's abort target; problemIndex/
|
||||
* problemTotal ride when the row belongs to a fan-out. */
|
||||
export interface ActivityPractice {
|
||||
id: string;
|
||||
kind: "generation" | "grading";
|
||||
title: string;
|
||||
phase: PracticePhase;
|
||||
startedAt: number;
|
||||
problemIndex?: number;
|
||||
problemTotal?: number;
|
||||
}
|
||||
|
||||
/** The dock's whole world in one payload: every running process, every view.
|
||||
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
|
||||
* the blob disappears when the process ends. Cost math is NOT done here:
|
||||
@@ -85,7 +68,6 @@ export interface ActivityPractice {
|
||||
export function activityFeed(): {
|
||||
jobs: ActivityJob[];
|
||||
extractions: ActivityExtraction[];
|
||||
practice: ActivityPractice[];
|
||||
graphRevision: number;
|
||||
} {
|
||||
const chats = chatStore.listChats();
|
||||
@@ -115,21 +97,11 @@ export function activityFeed(): {
|
||||
phase: run.phase,
|
||||
startedAt: run.startedAt,
|
||||
}));
|
||||
const practice: ActivityPractice[] = practiceStatuses().map((row) => ({
|
||||
id: row.key,
|
||||
kind: row.kind,
|
||||
title: row.label,
|
||||
phase: row.phase,
|
||||
startedAt: row.startedAt,
|
||||
problemIndex: row.problemIndex,
|
||||
problemTotal: row.problemTotal,
|
||||
}));
|
||||
// oldest-first on last activity: the stable blob order (ISO strings sort chronologically)
|
||||
jobs.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
||||
return {
|
||||
jobs,
|
||||
extractions: extractions.sort((a, b) => a.startedAt - b.startedAt),
|
||||
practice: practice.sort((a, b) => a.startedAt - b.startedAt),
|
||||
graphRevision: graphRevision(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* THE PRACTICE SERVICE (slice 4, 2026-09-29): the thin, route-facing
|
||||
* surface over the practice pipeline — request parsing/validation, typed
|
||||
* error → response mapping. Routes call THIS, never the pipeline
|
||||
* internals (the chats/graph service discipline).
|
||||
*
|
||||
* The pipeline instance is the chat-handler composition root's export
|
||||
* (`practice`, next to `extraction`): engines resolve fresh per stage
|
||||
* from BYOK settings, and refusals map to the 503 envelope family.
|
||||
*/
|
||||
|
||||
import { practice } from "./chat-handler.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import type { PracticeParams, ProblemSet } from "../core/contracts.ts";
|
||||
import {
|
||||
BadAnswersError,
|
||||
EmptySelectionError,
|
||||
PracticeSetNotFound,
|
||||
PracticeSetNotReady,
|
||||
} from "../agent/subagents/practice-pipeline.ts";
|
||||
import {
|
||||
ModelsNotConfiguredError,
|
||||
ProviderKeyMissingError,
|
||||
PROVIDER_KEY_MISSING_CODE,
|
||||
} from "../core/settings.ts";
|
||||
|
||||
// app/api uses base-URL charset ids (the activity/abort precedent).
|
||||
const ID_OK = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/** GET /api/practice/sets — the list, createdAt desc (store's order). */
|
||||
export function listPracticeSets(): ProblemSet[] {
|
||||
return stores.practiceStore.listSets();
|
||||
}
|
||||
|
||||
/** GET /api/practice/sets/[id] — one set, or null (→ 404). */
|
||||
export function getPracticeSet(id: string): ProblemSet | null {
|
||||
return stores.practiceStore.getSet(id);
|
||||
}
|
||||
|
||||
/** Strict params: three finite numbers in [0, 1]. */
|
||||
function parseParams(raw: unknown): PracticeParams | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const p = raw as Record<string, unknown>;
|
||||
// Named levels ONLY (owner, 2026-09-29): exact unions, nothing between —
|
||||
// a rung is a rung; there is no float to interpolate.
|
||||
if (p.ladderRung !== 0 && p.ladderRung !== 1 && p.ladderRung !== 2 && p.ladderRung !== 3 && p.ladderRung !== 4) return null;
|
||||
if (p.cueDistance !== 0 && p.cueDistance !== 1 && p.cueDistance !== 2) return null;
|
||||
if (p.presentation !== "theory" && p.presentation !== "application") return null;
|
||||
return {
|
||||
ladderRung: p.ladderRung,
|
||||
cueDistance: p.cueDistance,
|
||||
presentation: p.presentation,
|
||||
};
|
||||
}
|
||||
|
||||
/** The typed pipeline errors → HTTP (refusals → the 503 envelope family,
|
||||
* exactly like the import route's extraction mapping). */
|
||||
export function practiceErrorResponse(err: unknown): Response {
|
||||
if (err instanceof ModelsNotConfiguredError) {
|
||||
return Response.json(
|
||||
{ error: err.envelope.error, code: "models_not_configured", missing: err.envelope.missing },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (err instanceof ProviderKeyMissingError) {
|
||||
return Response.json(
|
||||
{
|
||||
error: err.envelope.error,
|
||||
code: PROVIDER_KEY_MISSING_CODE,
|
||||
provider: err.envelope.provider,
|
||||
slots: err.envelope.slots,
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 });
|
||||
if (err instanceof BadAnswersError) return Response.json({ error: err.message, code: "bad_answers" }, { status: 400 });
|
||||
if (err instanceof PracticeSetNotFound) return Response.json({ error: err.message, code: "not_found" }, { status: 404 });
|
||||
if (err instanceof PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 });
|
||||
console.error("[practice] unexpected:", err);
|
||||
return Response.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
|
||||
/** The learner-note cap (creation dialog; the textarea's own maxLength
|
||||
* matches — server-side is the law). */
|
||||
const NOTE_MAX = 2000;
|
||||
|
||||
/** POST /api/practice/sets — 202 {id}; the set generates in the
|
||||
* background (fire-and-forget pipeline). Body is {nodeIds, params,
|
||||
* title?, note?} ONLY (owner ruling 2026-09-29: problem sets take NODES —
|
||||
* graph objects; the UI resolves a picked cluster to its member ids,
|
||||
* and may pass the cluster label as the title; note is the learner's
|
||||
* free-form creation input, optional). */
|
||||
export async function createPracticeSetResponse(body: unknown): Promise<Response> {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
const params = parseParams(b.params);
|
||||
if (!params) {
|
||||
return Response.json(
|
||||
{ error: "params must carry ladderRung (0-4), cueDistance (0-2), presentation (theory|application) as exact levels" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const titleHint =
|
||||
typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
|
||||
let note: string | undefined;
|
||||
if (b.note !== undefined) {
|
||||
if (typeof b.note !== "string") {
|
||||
return Response.json({ error: "note must be a string" }, { status: 400 });
|
||||
}
|
||||
const trimmed = b.note.trim();
|
||||
if (trimmed.length > NOTE_MAX) {
|
||||
return Response.json({ error: `note is over ${NOTE_MAX} characters` }, { status: 400 });
|
||||
}
|
||||
note = trimmed || undefined; // whitespace-only = no note
|
||||
}
|
||||
if (
|
||||
!Array.isArray(b.nodeIds) ||
|
||||
!b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))
|
||||
) {
|
||||
return Response.json({ error: "Body must be {nodeIds, params, title?, note?}" }, { status: 400 });
|
||||
}
|
||||
if (b.nodeIds.length === 0) {
|
||||
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint, note);
|
||||
return Response.json({ id: set.id }, { status: 202 });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/practice/sets/[id]/answers — 202; grades land in the
|
||||
* background. Body: {answers: [{problemId, text}]}. */
|
||||
export async function submitPracticeAnswersResponse(setId: string, body: unknown): Promise<Response> {
|
||||
if (!ID_OK.test(setId)) return Response.json({ error: "Invalid set id" }, { status: 400 });
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
const raw = b.answers;
|
||||
if (!Array.isArray(raw) || raw.length === 0 || !raw.every((a): a is { problemId: string; text: string } =>
|
||||
typeof a === "object" && a !== null &&
|
||||
typeof (a as Record<string, unknown>).problemId === "string" &&
|
||||
typeof (a as Record<string, unknown>).text === "string",
|
||||
)) {
|
||||
return Response.json({ error: 'Body must be {answers: [{problemId: string, text: string}]}' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const { set } = await practice.submitAnswers(setId, raw);
|
||||
return Response.json({ id: set.id }, { status: 202 });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
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. */
|
||||
@@ -17,7 +16,6 @@ export const stores = {
|
||||
chatStore,
|
||||
graphStore,
|
||||
jobStore,
|
||||
practiceStore,
|
||||
settingsStore,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* 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>;
|
||||
// Named levels ONLY (owner, 2026-09-29): legacy float-params sets fail
|
||||
// here and are skipped from lists — no migration (owner wipes
|
||||
// data/practice before testing).
|
||||
return (
|
||||
(p.ladderRung === 0 ||
|
||||
p.ladderRung === 1 ||
|
||||
p.ladderRung === 2 ||
|
||||
p.ladderRung === 3 ||
|
||||
p.ladderRung === 4) &&
|
||||
(p.cueDistance === 0 || p.cueDistance === 1 || p.cueDistance === 2) &&
|
||||
(p.presentation === "theory" || p.presentation === "application")
|
||||
);
|
||||
};
|
||||
|
||||
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"]);
|
||||
function isGrade(g: unknown): boolean {
|
||||
if (typeof g !== "object" || g === null) return false;
|
||||
const v = g as Record<string, unknown>;
|
||||
// Integer 0–10 + non-empty reasoning (one-attempt redesign, owner
|
||||
// 2026-09-29: the grader scores; verdicts + prose feedback retired).
|
||||
return (
|
||||
typeof v.score === "number" &&
|
||||
Number.isInteger(v.score) &&
|
||||
v.score >= 0 &&
|
||||
v.score <= 10 &&
|
||||
typeof v.reasoning === "string" &&
|
||||
v.reasoning.length > 0 &&
|
||||
typeof v.gradedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
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 && !isGrade(p.grade)) 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" &&
|
||||
(v.note === undefined || typeof v.note === "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)}`,
|
||||
updatedAt: init.updatedAt ?? init.createdAt,
|
||||
};
|
||||
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");
|
||||
// The heartbeat: every write re-stamps (the boot sweep's grace window
|
||||
// reads this — a live run is structurally never 120s stale).
|
||||
set.updatedAt = new Date().toISOString();
|
||||
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