diff --git a/components/practice-dialog.tsx b/components/practice-dialog.tsx
index e54718d..e493745 100644
--- a/components/practice-dialog.tsx
+++ b/components/practice-dialog.tsx
@@ -104,7 +104,7 @@ export function PracticeDialogBody({
))}
{clusters.length === 0 && (
- No clusters yet — a cluster appears once a session births 3+ propositions.
+ No clusters yet — a cluster appears once a session births its first proposition.
)}
diff --git a/docs/architecture.md b/docs/architecture.md
index 5fec190..1cd4148 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -380,7 +380,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`), one row per set-phase (`setId:generation`, `setId:grading`), feeding the activity dock (GraduationCap rows; abort is set-scoped and idempotent; a boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — the job runner's crash-honesty at set grain). Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
-**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session clusters the graph view renders (`graphSnapshot`'s clusters verbatim — same ordering, same MIN_CLUSTER_SIZE presentation filter), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
+**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
**Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set).
diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts
index f9412f8..c92ac09 100644
--- a/src/core/contracts/graph.ts
+++ b/src/core/contracts/graph.ts
@@ -260,10 +260,11 @@ export interface Cluster {
/** One birth-session cluster as the practice picker consumes it (owner
* ruling 2026-09-29: problem sets take NODES — the UI resolves a picked
- * cluster to its member ids). Same grouping the graph view renders
- * (birthSessionPartition semantics, MIN_CLUSTER_SIZE presentation
- * filter included); `label` is null when the naming record is gone —
- * honest, never guessed (the graph view renders those unlabeled). */
+ * cluster to its member ids). EVERY birth-session group regardless of
+ * size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing
+ * rule — practice availability must not inherit a visual threshold);
+ * `label` is null when the naming record is gone — honest, never
+ * guessed (the graph view renders those unlabeled). */
export interface GraphClusterGroup {
/** The birth session's id — a chatId or a notes source id. */
key: string;
diff --git a/src/server/graph.ts b/src/server/graph.ts
index 1815339..8878c8f 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -4,6 +4,7 @@ import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
+import { birthSessionPartition } from "../core/session-clusters.ts";
import {
deleteNodeTraces,
nodeDeletionImpact,
@@ -23,21 +24,23 @@ export function graphView(): GraphSnapshot {
return graphSnapshot(stores);
}
-/** GET /api/graph/clusters — the birth-session groups the graph view
- * renders, resolved to node ids for the practice picker (owner ruling
- * 2026-09-29: cluster→nodeIds resolves in the UI; the API stays
- * graph-domain). Reuses the snapshot's clustering verbatim — same
- * ordering (size desc, ties by id) and the same MIN_CLUSTER_SIZE
- * presentation filter the canvas honors. A corrupt/empty graph is an
- * empty list. */
+/** GET /api/graph/clusters — the birth-session groups, resolved to node
+ * ids for the practice picker (owner ruling 2026-09-29: cluster→nodeIds
+ * resolves in the UI; the API stays graph-domain). EVERY group is
+ * listed regardless of size (owner ruling, same evening:
+ * MIN_CLUSTER_SIZE is a hull-DRAWING rule — practice availability must
+ * not inherit a visual threshold), with the partition's own ordering
+ * (size desc, ties by id asc). The label join mirrors the snapshot's:
+ * chat title / notes file path, null when the naming record is gone —
+ * honest, never guessed. A corrupt/empty graph is an empty list. */
export function graphClusters(): GraphClusterGroup[] {
const snap = graphSnapshot(stores);
- return snap.clusters.map((cluster) => ({
- key: cluster.id,
- kind: cluster.id in snap.sessions ? "chat" : "notes",
- label: cluster.label,
- nodeIds: cluster.members,
- nodeCount: cluster.members.length,
+ return birthSessionPartition(snap.nodes).map((group) => ({
+ key: group.id,
+ kind: group.id in snap.sessions ? "chat" : "notes",
+ label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null,
+ nodeIds: group.members,
+ nodeCount: group.members.length,
}));
}
diff --git a/tests/render-practice.test.tsx b/tests/render-practice.test.tsx
index ea8ea09..e8cef32 100644
--- a/tests/render-practice.test.tsx
+++ b/tests/render-practice.test.tsx
@@ -46,6 +46,15 @@ function clusters(): GraphClusterGroup[] {
];
}
+/** Small groups are listable (owner ruling: practice availability never
+ * inherits the hull-drawing threshold). */
+function smallClusters(): GraphClusterGroup[] {
+ return [
+ { key: "chat_2", kind: "chat", label: "One-off question", nodeIds: ["n20"], nodeCount: 1 },
+ { key: "chat_3", kind: "chat", label: "Quick exchange", nodeIds: ["n30", "n31"], nodeCount: 2 },
+ ];
+}
+
function stubProblem(id: string, chains: string[]): Problem {
return { id, chains, statement: "", solution: "", rubric: "", status: "generating" };
}
@@ -129,6 +138,15 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
it("an empty graph is an honest empty list", () => {
const html = renderToStaticMarkup();
assert.ok(html.includes("No clusters yet"));
+ assert.ok(html.includes("once a session births its first proposition"), "no 3+ mechanics in copy");
+ });
+
+ it("1- and 2-proposition groups appear — no hull threshold in the picker", () => {
+ const html = renderToStaticMarkup();
+ assert.ok(html.includes('data-testid="practice-cluster-chat_2"'), "singleton group listed");
+ assert.ok(html.includes("1 propositions"));
+ assert.ok(html.includes('data-testid="practice-cluster-chat_3"'), "pair group listed");
+ assert.ok(html.includes("2 propositions"));
});
it("empty selection (vanished nodes) renders the honest inline line", () => {