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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user