practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)

Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.

API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).

GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).

practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).

Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.

Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.

Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.

1130/1130, tsc 0, lint 0, build 0.
This commit is contained in:
amosblomqvist
2026-09-29 19:26:37 +03:00
parent 451caba1b1
commit f39e1b4294
13 changed files with 205 additions and 199 deletions
+21 -20
View File
@@ -20,7 +20,6 @@ import {
createPracticePipeline,
practiceStatuses,
EmptySelectionError,
UnknownChatError,
PracticeSetNotFound,
PracticeSetNotReady,
BadAnswersError,
@@ -230,7 +229,6 @@ function fixture(partial: Partial<Parameters<typeof createPracticePipeline>[0]>
const deps = {
graphStore: diamondGraph(),
practiceStore: practice.store,
chatStore: memoryChats(),
...partial,
};
return { practice, deps, make: () => createPracticePipeline(deps) };
@@ -273,7 +271,7 @@ describe("createProblemSet (generation)", () => {
grader: echoGrader(1),
});
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "c", "d", "e"] }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
assert.match(set.id, /^ps_/);
assert.equal(set.status, "partitioning");
@@ -307,7 +305,7 @@ describe("createProblemSet (generation)", () => {
it("per-problem failure isolation: one generator fails, the set still completes", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "e"] }, PARAMS); // chains b + e
const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS); // chains b + e
await done;
const final = f.practice.store.getSet(set.id)!;
assert.equal(final.status, "ready");
@@ -347,7 +345,7 @@ describe("createProblemSet (generation)", () => {
const deps = { ...f.deps, problemGenerator: fauxEngine(Array.from({ length: 5 }, () => factory)), grader: echoGrader(1) };
const pipeline = createPracticePipeline(deps);
const { done } = await pipeline.createProblemSet({ nodeIds: nodes.map((n) => n.id) }, PARAMS);
const { done } = await pipeline.createProblemSet(nodes.map((n) => n.id), PARAMS);
await until(() => active === 3);
assert.equal(max, 3, "exactly three generators in flight");
release();
@@ -358,7 +356,7 @@ describe("createProblemSet (generation)", () => {
it("abort mid-generation: controllers die, everything generating is 'Aborted'", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }, { chains: ["d"] }, { chains: ["e"] }]), problemGenerator: hangingModel(4), grader: echoGrader(1) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "c", "d", "e"] }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
await until(() => f.practice.store.getSet(set.id)?.status === "generating");
pipeline.abortPractice(set.id);
@@ -390,7 +388,7 @@ describe("createProblemSet (generation)", () => {
]);
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: gen, grader: echoGrader(1) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["b"] }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
await until(() => practiceStatuses().length === 1 && practiceStatuses()[0]!.phase === "generating");
seen = practiceStatuses()[0]!;
assert.equal(seen.setId, set.id);
@@ -406,7 +404,7 @@ describe("createProblemSet (generation)", () => {
it("refusal gate fires BEFORE anything is created", async () => {
const f = fixture({}); // no engines, no engineFor
const pipeline = f.make();
await assert.rejects(pipeline.createProblemSet({ chatId: "chat_1" }, PARAMS), ModelsNotConfiguredError);
await assert.rejects(pipeline.createProblemSet(["a", "b"], PARAMS), ModelsNotConfiguredError);
assert.equal(f.practice.sets.size, 0, "no set persisted on refusal");
});
});
@@ -414,22 +412,25 @@ describe("createProblemSet (generation)", () => {
// ─── selection + typed errors ────────────────────────────────────────────────
describe("selection resolution + typed errors", () => {
it("chatId: every node with ANY provenance point in the chat", async () => {
it("nodeIds: the raw list filtered to live nodes (unknown ids drop)", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b", "c", "d", "e"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ chatId: "chat_1" }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e", "n_ghost"], PARAMS);
await done;
const final = f.practice.store.getSet(set.id)!;
assert.deepEqual(final.nodeIds.sort(), ["a", "b", "c", "d", "e"]);
assert.deepEqual(final.nodeIds.sort(), ["a", "b", "c", "d", "e"], "unknown ids silently drop");
assert.deepEqual(final.chains.map((c) => c.id).sort(), ["b", "c", "d", "e"]);
assert.ok(final.title.startsWith("Practice — "), "no title hint → the dated default");
});
it("unknown chat → UnknownChatError; empty selection → EmptySelectionError", async () => {
it("empty selection / vanished nodes → EmptySelectionError; title hint rides", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make();
await assert.rejects(pipeline.createProblemSet({ chatId: "chat_x" }, PARAMS), UnknownChatError);
await assert.rejects(pipeline.createProblemSet({ nodeIds: [] }, PARAMS), EmptySelectionError);
await assert.rejects(pipeline.createProblemSet({ nodeIds: ["n_ghost"] }, PARAMS), EmptySelectionError);
await assert.rejects(pipeline.createProblemSet([], PARAMS), EmptySelectionError);
await assert.rejects(pipeline.createProblemSet(["n_ghost"], PARAMS), EmptySelectionError);
const { set, done } = await pipeline.createProblemSet(["a", "b"], PARAMS, "Ethics cluster");
await done;
assert.equal(f.practice.store.getSet(set.id)!.title, "Ethics cluster");
});
});
@@ -439,7 +440,7 @@ describe("submitAnswers (grading)", () => {
async function readySet() {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(2), grader: echoGrader(2) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "e"] }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS);
await done;
return { f, pipeline, set: f.practice.store.getSet(set.id)! };
}
@@ -472,13 +473,13 @@ describe("submitAnswers (grading)", () => {
const f2 = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const p2 = f2.make();
const { set: s2 } = await p2.createProblemSet({ nodeIds: ["b"] }, PARAMS); // stuck partitioning
const { set: s2 } = await p2.createProblemSet(["b"], PARAMS); // stuck partitioning
await assert.rejects(p2.submitAnswers(s2.id, [{ problemId: "pp_x", text: "t" }]), PracticeSetNotReady);
p2.abortPractice(s2.id);
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pl = f.make();
const { set: s3, done } = await pl.createProblemSet({ nodeIds: ["b"] }, PARAMS);
const { set: s3, done } = await pl.createProblemSet(["b"], PARAMS);
await done;
await assert.rejects(pl.submitAnswers(s3.id, [{ problemId: "pp_ghost", text: "t" }]), BadAnswersError);
const settled = f.practice.store.getSet(s3.id)!; // the post-done record (s3 itself is the creation stub)
@@ -510,7 +511,7 @@ describe("boot sweep + delete", () => {
it("a LIVE generating set (registry row) is NOT swept by a second instance", async () => {
const f = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const a = f.make();
const { set, done } = await a.createProblemSet({ nodeIds: ["b"] }, PARAMS); // hangs partitioning
const { set, done } = await a.createProblemSet(["b"], PARAMS); // hangs partitioning
f.make(); // second pipeline instance, same process → same registry
assert.equal(f.practice.store.getSet(set.id)!.status, "partitioning");
a.abortPractice(set.id);
@@ -520,7 +521,7 @@ describe("boot sweep + delete", () => {
it("delete: aborts live jobs first, drops the record; unknown → false", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["b"] }, PARAMS);
const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
await done;
assert.equal(await pipeline.deleteProblemSet(set.id), true);
assert.equal(f.practice.store.getSet(set.id), null);
+33 -27
View File
@@ -14,12 +14,14 @@ import {
PracticeDialogBody,
type PracticeFailure,
} from "../components/practice-dialog.tsx";
import type { GraphClusterGroup } from "../src/core";
import { PracticeExamBody } from "../components/practice-exam.tsx";
import type { ChatListItem, Problem, ProblemSet } from "../src/core";
import type { Problem, ProblemSet } from "../src/core";
/**
* The practice UI surfaces (slice 5, 2026-09-29): creation dialog (dials
* + session picker + preselected lock), list page (rows, delete flow),
* The practice UI surfaces (slice 5, 2026-09-29; cluster-picker fix the
* same evening — graph-domain, not chat-domain): creation dialog (dials
* + cluster picker), list page (rows, delete flow),
* exam page states (generating stubs — propositions never surface —
* ready + answerable, graded reveal + lock). The pure bodies are pinned
* here (the render-suite discipline); the live fetch chains (polling,
@@ -36,10 +38,11 @@ const dialValues = {
presentation: 0.3,
};
function chats(): ChatListItem[] {
function clusters(): GraphClusterGroup[] {
return [
{ id: "chat_1", title: "Ethics lesson", updatedAt: "2026-09-29T10:00:00.000Z", createdAt: "2026-09-29T09:00:00.000Z", busy: false } as ChatListItem,
{ id: "chat_2", title: "Physics", updatedAt: "2026-09-28T10:00:00.000Z", createdAt: "2026-09-28T09:00:00.000Z", busy: false } as ChatListItem,
{ key: "chat_1", kind: "chat", label: "Ethics lesson", nodeIds: ["n1", "n2", "n3", "n4"], nodeCount: 4 },
{ key: "src_ab12", kind: "notes", label: "~/notes/physics.md", nodeIds: ["n5", "n6", "n7"], nodeCount: 3 },
{ key: "chat_9", kind: "chat", label: null, nodeIds: ["n8", "n9", "n10"], nodeCount: 3 },
];
}
@@ -81,14 +84,13 @@ function set(over: Partial<ProblemSet> = {}): ProblemSet {
// ─── the creation dialog ─────────────────────────────────────────────────────
describe("PracticeDialogBody — creation", () => {
describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = {
values: dialValues,
onDial: () => {},
chats: chats(),
selectedId: null,
clusters: clusters(),
selectedKey: null,
onSelect: () => {},
lockedTitle: null,
busy: false,
failure: null as PracticeFailure | null,
onOpenSettings: () => {},
@@ -105,37 +107,41 @@ describe("PracticeDialogBody — creation", () => {
}
});
it("session picker: titled rows, selection lights up, Create gated until picked", () => {
it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes('data-testid="practice-dialog-sessions"'));
assert.ok(html.includes('data-testid="practice-dialog-clusters"'));
assert.ok(html.includes('data-testid="practice-cluster-chat_1"'));
assert.ok(html.includes("Ethics lesson"));
assert.ok(html.includes('disabled=""'), "no session picked — Create disabled");
assert.match(html, /notes · <!-- -->3 propositions|notes · 3 propositions/);
assert.ok(html.includes("4 propositions"), "chat cluster shows its count");
assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled");
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedId="chat_2" />);
assert.ok(picked.includes('data-testid="practice-session-chat_2"'));
assert.match(picked, /Physics<\/span>.*selected/s);
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
assert.ok(picked.includes("~/notes/physics.md"));
});
it("preselected mode: picker hidden, the session locked", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} chats={null} lockedTitle="Ethics lesson" />,
);
assert.ok(html.includes('data-testid="practice-dialog-locked"'));
assert.ok(!html.includes('data-testid="practice-dialog-sessions"'));
assert.ok(!html.includes('disabled=""'), "preselected — Create enabled");
it("an unlabeled cluster (naming record gone) renders muted, never guessed", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes("unlabeled cluster"));
assert.ok(html.includes('data-testid="practice-cluster-chat_9"'));
});
it("empty selection renders the honest inline line", () => {
it("an empty graph is an honest empty list", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={[]} />);
assert.ok(html.includes("No clusters yet"));
});
it("empty selection (vanished nodes) renders the honest inline line", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />,
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />,
);
assert.ok(html.includes('data-testid="practice-dialog-error"'));
assert.ok(html.includes("This session has no propositions yet"));
assert.ok(html.includes("Nothing practicable is left in that cluster"));
});
it("503 renders the server's line + the settings deep link", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} lockedTitle="Ethics lesson" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />,
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />,
);
assert.ok(html.includes("No model configured"));
assert.ok(html.includes('data-testid="practice-dialog-open-settings"'));