practice slice 1 — contracts, the chain bijection, coverage
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.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<Pick<GraphNode, "id">>,
|
||||
edges: ReadonlyArray<Pick<GraphEdge, "from" | "to" | "type">>,
|
||||
): ReasoningChain[] {
|
||||
const selected = new Set(nodes.map((n) => n.id));
|
||||
const premisesByConclusion = new Map<string, Set<string>>();
|
||||
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<string>();
|
||||
premises.add(edge.from);
|
||||
premisesByConclusion.set(edge.to, premises);
|
||||
}
|
||||
const chained = new Set<string>();
|
||||
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<Pick<ReasoningChain, "id" | "premises" | "conclusion">>,
|
||||
): {
|
||||
nodeIds: string[];
|
||||
edges: Array<{ from: string; to: string; type: "follows" }>;
|
||||
} {
|
||||
const nodeIds = new Set<string>();
|
||||
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<Pick<ReasoningChain, "id">>;
|
||||
problems: ReadonlyArray<Pick<Problem, "chains">>;
|
||||
},
|
||||
): ChainCoverageReport {
|
||||
const counts = new Map<string, number>();
|
||||
for (const problem of set.problems) {
|
||||
for (const chainId of problem.chains) {
|
||||
counts.set(chainId, (counts.get(chainId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const missing: string[] = [];
|
||||
const duplicated: string[] = [];
|
||||
for (const chain of set.chains) {
|
||||
const count = counts.get(chain.id) ?? 0;
|
||||
if (count === 0) missing.push(chain.id);
|
||||
else if (count > 1) duplicated.push(chain.id);
|
||||
}
|
||||
return { missing: missing.sort(), duplicated: duplicated.sort() };
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user