From 05dcaafc68746162c9888838dad9e8ca77f62213 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 17:48:20 +0300 Subject: [PATCH] =?UTF-8?q?practice=20slice=201=20=E2=80=94=20contracts,?= =?UTF-8?q?=20the=20chain=20bijection,=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/core/contracts/practice.ts (new domain): ReasoningChain (group by conclusion, id = conclusion node id), PracticeParams (three 0..1 dials), ProblemSet/Problem lifecycle, feedback-only ProblemGrade. src/core/ practice.ts: chainsForSelection (follows-only, both-ends-in-selection, group-by-conclusion, singletons for edge-less nodes), chainsToGraph (the exact inverse — bijection pinned), chainCoverage (the completeness law: union of problems' chains = the set's chains; duplicated allowed). 13-test suite: diamond, premise-only, singleton, supports-invisible, outside-endpoint, dedupe, determinism, round-trips, coverage cases. --- src/core/contracts.ts | 8 +- src/core/contracts/practice.ts | 137 ++++++++++++++++++++++++++++++++ src/core/practice.ts | 104 ++++++++++++++++++++++++ tests/practice.test.ts | 140 +++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 src/core/contracts/practice.ts create mode 100644 src/core/practice.ts create mode 100644 tests/practice.test.ts diff --git a/src/core/contracts.ts b/src/core/contracts.ts index 13d7e8e..af29acc 100644 --- a/src/core/contracts.ts +++ b/src/core/contracts.ts @@ -4,9 +4,10 @@ * 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). 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. + * protocol), practice.ts (the practice-problem system). 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. */ export * from "./contracts/plan.ts"; @@ -15,3 +16,4 @@ 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"; diff --git a/src/core/contracts/practice.ts b/src/core/contracts/practice.ts new file mode 100644 index 0000000..f00885f --- /dev/null +++ b/src/core/contracts/practice.ts @@ -0,0 +1,137 @@ +/** + * 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 three dials every problem set is configured with (owner, 2026-09-29) + * — all 0..1, interpreted by the agents against the framework's axes. */ +export interface PracticeParams { + /** Search-to-solution ratio — the main axis: flashcard recall (0) to + * competition problem (1). */ + searchSolutionRatio: number; + /** Cue distance from every situation already used (0 = near the + * lesson, 1 = far transfer — still inside the recognizable band). */ + cueDistance: number; + /** Presentation axis: pure theory (0) to real-world application (1). */ + presentation: number; +} + +/** 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 grader's three-level verdict (feedback-only; nothing derives from + * it — no understanding writes, owner 2026-09-29). */ +export type GradeVerdict = "solid" | "partial" | "missed"; + +/** The grading job's deliverable, stored on the problem record. */ +export interface ProblemGrade { + verdict: GradeVerdict; + /** What actually mattered: the failed recall named, the boundary + * taught — against the pre-written solution + rubric. */ + feedback: 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 grading job's verdict, 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 — auto-generated (the session's title, or + * date-based). */ + title: string; + createdAt: 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[]; + status: ProblemSetStatus; + /** Set when the partition itself (or the run as a whole) failed. */ + error?: string; + /** Problem stubs appear here the moment the partitioner submits — + * numbered by position, "generating", propositions invisible. */ + problems: Problem[]; +} diff --git a/src/core/practice.ts b/src/core/practice.ts new file mode 100644 index 0000000..fce973c --- /dev/null +++ b/src/core/practice.ts @@ -0,0 +1,104 @@ +/** + * Practice-module core — the chain bijection + coverage (slice 1 of the + * 2026-09-29 plan). Pure, no I/O. + * + * chainsForSelection(): the graph → chains translation — group by + * conclusion over FOLLOWS edges only (supports are invisible to practice, + * owner 2026-09-29: "no edges"). chainsToGraph(): the exact inverse — the + * bijection the chain notation promises (renderable back to the graph + * with no information loss; the round-trip is pinned in + * tests/practice.test.ts). 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 { GraphEdge, GraphNode, Problem, ReasoningChain } from "./contracts.ts"; + +/** The selection's follows-edges as chains: every edge into a node joins + * that node's (one) chain; a node with no in- AND no out-follows-edge in + * the selection is a singleton chain. Premise-only nodes are NOT separate + * chains — they are covered inside the chains they feed. Deterministic: + * chains sorted by id, premises sorted. */ +export function chainsForSelection( + nodes: ReadonlyArray>, + edges: ReadonlyArray>, +): ReasoningChain[] { + const selected = new Set(nodes.map((n) => n.id)); + const premisesByConclusion = new Map>(); + for (const edge of edges) { + if (edge.type !== "follows") continue; // supports are NO edges here (owner, 2026-09-29) + if (!selected.has(edge.from) || !selected.has(edge.to)) continue; + const premises = premisesByConclusion.get(edge.to) ?? new Set(); + premises.add(edge.from); + premisesByConclusion.set(edge.to, premises); + } + const chained = new Set(); + const chains: ReasoningChain[] = []; + for (const [conclusion, premises] of premisesByConclusion) { + chains.push({ id: conclusion, premises: [...premises].sort(), conclusion }); + chained.add(conclusion); + for (const premise of premises) chained.add(premise); + } + for (const node of nodes) { + if (!chained.has(node.id)) { + chains.push({ id: node.id, premises: [], conclusion: node.id }); + } + } + return chains.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** The exact inverse of chainsForSelection: node ids = every chain member, + * edges = one follows-edge per (premise, conclusion) pair. Deterministic: + * node ids sorted, edges sorted by (from, to). */ +export function chainsToGraph( + chains: ReadonlyArray>, +): { + nodeIds: string[]; + edges: Array<{ from: string; to: string; type: "follows" }>; +} { + const nodeIds = new Set(); + const edges: Array<{ from: string; to: string; type: "follows" }> = []; + for (const chain of chains) { + nodeIds.add(chain.conclusion); + for (const premise of chain.premises) { + nodeIds.add(premise); + edges.push({ from: premise, to: chain.conclusion, type: "follows" }); + } + } + edges.sort((a, b) => (a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.to < b.to ? -1 : 1)); + return { nodeIds: [...nodeIds].sort(), edges }; +} + +/** 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>; + problems: ReadonlyArray>; + }, +): ChainCoverageReport { + const counts = new Map(); + 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() }; +} diff --git a/tests/practice.test.ts b/tests/practice.test.ts new file mode 100644 index 0000000..d817a0d --- /dev/null +++ b/tests/practice.test.ts @@ -0,0 +1,140 @@ +/** + * PRACTICE-MODULE CORE (slice 1, 2026-09-29 plan): the chain bijection — + * chainsForSelection (group by conclusion, FOLLOWS ONLY — supports are + * invisible to practice, owner ruling) / chainsToGraph (the exact inverse, + * round-trip pinned) — and chainCoverage (the set's completeness law: + * union of the problems' chains must equal the set's chains). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { EdgeType } from "../src/core/contracts.ts"; +import { chainCoverage, chainsForSelection, chainsToGraph } from "../src/core/practice.ts"; + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +const n = (id: string) => ({ id }); +const e = (from: string, to: string, type: EdgeType = "follows") => ({ from, to, type }); + +/** The plan's canonical shape: diamond a→b, a→c, b→d, c→d. */ +const diamond = { + nodes: [n("a"), n("b"), n("c"), n("d")], + edges: [e("a", "b"), e("a", "c"), e("b", "d"), e("c", "d")], +}; +const diamondChains = [ + { id: "b", premises: ["a"], conclusion: "b" }, + { id: "c", premises: ["a"], conclusion: "c" }, + { id: "d", premises: ["b", "c"], conclusion: "d" }, +]; + +// ─── chainsForSelection ────────────────────────────────────────────────────── + +describe("chainsForSelection", () => { + it("groups by conclusion: the diamond a→b, a→c, b→d, c→d is three chains", () => { + assert.deepEqual(chainsForSelection(diamond.nodes, diamond.edges), diamondChains); + }); + + it("a premise-only node is NOT its own chain — it lives inside the chains it feeds", () => { + const ids = chainsForSelection(diamond.nodes, diamond.edges).map((c) => c.id); + assert.ok(!ids.includes("a")); // a is only ever a premise + }); + + it("an isolated node becomes a singleton chain", () => { + const chains = chainsForSelection([...diamond.nodes, n("z")], diamond.edges); + assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" }); + }); + + it("supports edges are NO edges — nodes joined only by supports are singletons", () => { + const chains = chainsForSelection([n("x"), n("y")], [e("x", "y", "supports")]); + assert.deepEqual(chains, [ + { id: "x", premises: [], conclusion: "x" }, + { id: "y", premises: [], conclusion: "y" }, + ]); + }); + + it("an edge with an endpoint outside the selection is excluded entirely", () => { + // a→out would chain a; out is not selected, so the edge is gone and + // a (nothing else left) falls back to a singleton. + const chains = chainsForSelection([n("a")], [e("a", "out"), e("in", "a")]); + assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]); + }); + + it("duplicate edge assertions collapse into one premise", () => { + const chains = chainsForSelection([n("a"), n("b")], [e("a", "b"), e("a", "b")]); + assert.deepEqual(chains, [{ id: "b", premises: ["a"], conclusion: "b" }]); + }); + + it("is deterministic — chains and premises sorted by id", () => { + const shuffled = chainsForSelection( + [n("d"), n("c"), n("b"), n("a")], + [e("c", "d"), e("a", "c"), e("a", "b"), e("b", "d")], + ); + assert.deepEqual(shuffled, diamondChains); + }); +}); + +// ─── chainsToGraph — the bijection ─────────────────────────────────────────── + +describe("chainsToGraph (the inverse)", () => { + it("reproduces exactly the selection's follows-edges (both ends selected)", () => { + const supportsNoise = e("x", "y", "supports"); + const outside = e("a", "out"); + const { edges } = chainsToGraph(chainsForSelection(diamond.nodes, [...diamond.edges, supportsNoise, outside])); + assert.deepEqual(edges, [...diamond.edges]); + }); + + it("round-trips a canonical chain list — singletons survive only for edge-less nodes", () => { + // NOTE: the bijection is over chainsForSelection's IMAGE — lists where a + // singleton's node never also appears as a premise/conclusion elsewhere. + // (A "singleton + premise elsewhere" list is degenerate: not something + // the translation ever produces.) + const canonical = [ + { id: "p1", premises: ["a", "b"], conclusion: "p1" }, + { id: "p9", premises: [], conclusion: "p9" }, + { id: "c1", premises: ["p1"], conclusion: "c1" }, + ]; + const nodeIds = [...new Set(canonical.flatMap((c) => [c.conclusion, ...c.premises]))]; + const roundTripped = chainsForSelection(nodeIds.map(n), chainsToGraph(canonical).edges); + assert.deepEqual(roundTripped, [ + { id: "c1", premises: ["p1"], conclusion: "c1" }, + { id: "p1", premises: ["a", "b"], conclusion: "p1" }, + { id: "p9", premises: [], conclusion: "p9" }, + ]); + }); +}); + +// ─── chainCoverage ─────────────────────────────────────────────────────────── + +describe("chainCoverage", () => { + it("complete cover: every chain exercised, no missing", () => { + const set = { + chains: diamondChains, + problems: [{ chains: ["b", "c"] }, { chains: ["d"] }], + }; + assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); + }); + + it("missing: a chain no problem exercises", () => { + const set = { + chains: diamondChains, + problems: [{ chains: ["d"] }], + }; + assert.deepEqual(chainCoverage(set), { missing: ["b", "c"], duplicated: [] }); + }); + + it("duplicated is informational — a chain exercised twice is allowed", () => { + const set = { + chains: diamondChains, + problems: [{ chains: ["b", "c", "d"] }, { chains: ["b", "d"] }], + }; + assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["b", "d"] }); + }); + + it("problems claiming chain ids outside the set neither cover nor fail anything", () => { + const set = { + chains: [{ id: "b" }] as Array<{ id: string }>, + problems: [{ chains: ["b", "ghost"] }], + }; + assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); + }); +});