Files
learnharness/tests/session-clusters.test.ts
T
amosblomqvist af666aaa3f physics: bundles join their cluster; depth runs over the logical graph
Owner-approved brief (orchestrator, physics arc): two laws.

1. IMP-NODE CLUSTER ASSIGNMENT. Imp nodes were unclustered (the partition
   runs over stored propositions only) — no centroid pull, outside the
   painted hulls, bundles sitting on the periphery pointing outward.
   Now: a derived imp node's cluster is a function of its derivation —
   MAJORITY among its PREMISES' birth sessions (owner: premises, NOT the
   conclusion), ties to the chain's OWN birth (its provenance's first
   point — the asserting session; deterministic, coincides with the
   unanimous common case). Imp nodes are always re-derived, never
   birthed, so the assignment must be exactly this.

   Seam chosen: core/session-clusters.ts exports impClusterKey + a
   shared attachImpToCommunities that mutates a communities list's
   members IN PLACE. graphSnapshot attaches AFTER the hull filter (a
   2-proposition community + its bundle never resurrects a hull — the
   filter counts propositions); settleGraphLayout attaches to the
   unfiltered partition (settle pulls every community; hulls are a
   canvas-only concern). Both cluster-membership consumers — the sim's
   centroid pull and buildRegions' rasterization — read it through the
   members lists that went through the one helper, so the resting
   ledger and the live sim agree on where bundles belong.

2. DEPTHS OVER THE LOGICAL GRAPH. inferenceDepths ran Tarjan over the
   DRAWN edges (premise→imp→conclusion), inflating every multi-premise
   conclusion one hop (depth(imp)+1 instead of depth(premises)+1) and
   ranking the geometry point itself. Now:
   - deriveView additionally emits logicalEdges — every chain contracted
     to direct premise→conclusion arrows over PROPOSITION ids only
     (per-premise, deduped — a conclusion's rank is max over premises,
     which a single arbitrary premise edge could not express).
   - GraphSnapshot carries logicalEdges; createGraphSimulation /
     incrementalLayout accept a logicalEdges option: the depth
     machinery (orphan detection AND the rank pass) reads it, springs
     keep the drawn edges. Absent = edges (legacy behavior).
   - Imp nodes join the ORPHAN-EXEMPTION mask explicitly — never pinned
     to a level, keep the full regular forces (gravity, cluster pull,
     springs), settle naturally between premise and conclusion levels;
     the rank map is computed over propositions only, so arg_ ids are
     absent from it by construction.
   Wiring: settle + canvas (CanvasData.logicalEdges → buildSim) +
   replay-layout all pass the logical set.

   EXTRA FIX (in-slice discovery, one line, graph-server domain):
   server/graph.ts graphClusters — the practice picker's /api/graph/
   clusters — partitioned snap.NODES (the canvas image, imp nodes riding
   it since chains-primary), leaking arg_ ids into nodeIds: pickable ids
   that silently vanish inside chainsForSelection while nodeCount lied.
   Now partitions snap.propositions — same purity law as the chain
   table's 767a559 fix.

Tests (+13): impClusterKey pins (unanimous; cross-session majority; tie
→ chain's own birth, not the conclusion's); attach pins (members gain
the imp id, sorted; single-premise attaches nothing — no node exists;
dangling mints nothing; home absent from the list stays unattached);
snapshot pins (imp id rides cluster members to both consumers; the hull
filter counts propositions); logical-depth pins (multi-premise
conclusion at premises'+1 NOT +2; imp absent from the rank map);
depth-force exemption pin (rows pin crisply, arg_x floats unpinned —
old behavior stamped it onto the conclusion's row); deriveView logical
arrows (per-premise, deduped, proposition ids only).
2026-09-30 04:30:25 +03:00

356 lines
15 KiB
TypeScript

/**
* BIRTH-SESSION CLUSTERING + THE PROBER GRAPH-TOOLS DATA SEAM (wave 7,
* owner-locked 2026-09-23): embeddings are dead — a proposition's cluster
* is the session that created it, derived on read from provenance
* (session-clusters.ts); labels are session names; the prober tools read a
* session list + per-session propositions with the extractor's latest
* understanding level and its own timestamp. All in-memory fakes — the
* fs-backed snapshot join (with the real store adapters) lives in
* graph.test.ts.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { GraphChain, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts";
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
import {
graphSessionIndex,
graphSessionPropositions,
graphSnapshot,
type GraphDeps,
} from "../src/core/graph.ts";
import {
attachImpToCommunities,
birthSessionOf,
birthSessionPartition,
impClusterKey,
} from "../src/core/session-clusters.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
/** attachImpToCommunities takes a structural Pick; full chains are
* assignable but literals need the widening. */
const attachable = (c: GraphChain): Pick<GraphChain, "id" | "premises" | "conclusion" | "provenance"> => c;
const node = (id: string, name: string, provenance: Array<{ chatId: string; messageId: string }>): GraphNode => ({
id,
name,
statement: `${name} statement`,
aliases: [],
createdAt: "2026-09-23T00:00:00.000Z",
provenance,
});
function fakeStores(
nodes: GraphNode[],
over: {
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
chains?: GraphChain[];
} = {},
): GraphDeps {
const chains: GraphChain[] = over.chains ?? [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readChains: () => chains,
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () =>
(over.sources ?? []).map((s) => ({ ...s, extractions: [] }) as unknown as NotesSource),
readPositions: () => ({}),
writePositions: () => {},
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
getChat: (id: string) =>
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
} as unknown as ChatStore;
return { chatStore, graphStore };
}
// ─── the partition ──────────────────────────────────────────────────────────
describe("birthSessionPartition", () => {
it("groups by the first provenance point, dedupes repeated lines, sorts deterministically", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat2")]),
node("a", "A-linked", [pt("chat2")]), // link-append line for "a": ignored (first defines)
node("d", "D", [pt("chat1")]),
];
assert.deepEqual(birthSessionPartition(nodes), [
{ id: "chat1", members: ["a", "b", "d"] },
{ id: "chat2", members: ["c"] },
]);
});
it("a SHARED node belongs to its birth session only — later contributors never claim it", () => {
const nodes = [
node("born-1", "X", [pt("chat1")]),
node("born-1", "X-linked", [pt("chat2")]), // chat2 re-encountered it
node("born-2", "Y", [pt("chat2")]),
];
assert.deepEqual(birthSessionPartition(nodes), [
{ id: "chat1", members: ["born-1"] },
{ id: "chat2", members: ["born-2"] },
], "chat2 holds only what it BIRTHED — sizes tie (1/1), id order breaks it");
});
it("deletion re-attribution: a stripped birth falls to the earliest remaining contributor; no provenance = unclustered", () => {
// What delete-nodes leaves behind: the birth session's points are
// stripped from surviving lines — the merged provenance walks forward.
const reattributed = node("survivor", "S", [pt("chat2"), pt("chat3")]);
assert.equal(birthSessionOf(reattributed), "chat2");
const naked = node("naked", "N", []);
assert.equal(birthSessionOf(naked), null);
const partition = birthSessionPartition([reattributed, naked]);
assert.deepEqual(partition, [{ id: "chat2", members: ["survivor"] }], "the naked node joins nothing");
});
});
// ─── notes files as birth sessions (production shape) ────────────────────────
describe("notes files as birth sessions", () => {
// The mapping-line fallback is GONE (owner, 2026-09-29): the mappings
// ledger itself died with per-job in-memory resolution, and every merge
// writes provenance points — the src id IS the birth session.
it("graphSessionIndex lists imported files as kind:file with birth counts (production shape)", () => {
const nodes = [node("a", "Alpha", [pt("src_abc")]), node("b", "Beta", [pt("src_abc")]), node("c", "Gamma", [pt("src_abc")])];
const deps = fakeStores(nodes, {
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
const index = graphSessionIndex(deps);
assert.deepEqual(index, [
{
id: "src_abc",
name: "/vault/learn vim.md",
kind: "file",
createdAt: "2026-09-22T00:00:00.000Z",
updatedAt: "2026-09-22T00:00:00.000Z",
propositions: 3,
},
], "the file births under its src id; the childless chat has no entry (birth sessions only)");
});
it("graphSessionPropositions lists the file's births under the src id (a pure birth list, 2026-09-27)", () => {
const deps = fakeStores([node("a", "Alpha", [pt("src_abc")]), node("b", "Beta", [pt("src_abc")])], {
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
assert.deepEqual(graphSessionPropositions(deps, "src_abc"), [
{ nodeId: "a", name: "Alpha" },
{ nodeId: "b", name: "Beta" },
]);
});
});
// ─── the snapshot join ──────────────────────────────────────────────────────
describe("graphSnapshot birth-session clusters", () => {
it("labels clusters with session names (chat title / file path), keeps the ≥3 hull rule", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat1")]),
node("d", "D", [pt("src_notes")]),
node("e", "E", [pt("src_notes")]),
node("f", "F", [pt("src_notes")]),
];
const deps = fakeStores(nodes, {
chats: [{ id: "chat1", title: "Learning session", createdAt: "2026-09-20T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z" }],
sources: [{ id: "src_notes", filePath: "/vault/notes.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
const snap = graphSnapshot(deps);
assert.equal(snap.clusters.length, 2);
const byLabel = new Map(snap.clusters.map((c) => [c.label, c]));
assert.deepEqual(byLabel.get("Learning session"), {
id: "chat1",
label: "Learning session",
members: ["a", "b", "c"],
});
assert.deepEqual(byLabel.get("/vault/notes.md"), {
id: "src_notes",
label: "/vault/notes.md",
members: ["d", "e", "f"],
}, "a notes file is a birth-session too (owner ruling)");
// A 2-proposition session renders no hull (the presentation rule).
const two = fakeStores([node("x", "X", [pt("chat1")]), node("y", "Y", [pt("chat1")])], {
chats: [{ id: "chat1", title: "tiny", createdAt: "", updatedAt: "" }],
});
assert.equal(graphSnapshot(two).clusters.length, 0);
});
it("a birth session whose record is gone keeps its members but renders unlabeled", () => {
const nodes = [node("a", "A", [pt("gone")]), node("b", "B", [pt("gone")]), node("c", "C", [pt("gone")])];
const snap = graphSnapshot(fakeStores(nodes));
assert.equal(snap.clusters.length, 1);
assert.equal(snap.clusters[0]!.label, null);
assert.equal(snap.clusters[0]!.id, "gone");
});
});
// ─── the prober data seam ───────────────────────────────────────────────────
describe("graphSessionIndex (the prober's session list)", () => {
it("birth counts, chat/file kinds, recency order, deleted records fall back honestly", () => {
const nodes = [
node("a", "A", [pt("chat1")]),
node("b", "B", [pt("chat1")]),
node("c", "C", [pt("chat1")]),
node("d", "D", [pt("src_notes")]),
node("e", "E", [pt("gone")]), // its chat record no longer exists
];
const deps = fakeStores(nodes, {
chats: [
{ id: "chat1", title: "Older session", createdAt: "2026-09-20T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z" },
],
sources: [{ id: "src_notes", filePath: "/vault/notes.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
const index = graphSessionIndex(deps);
assert.deepEqual(index, [
{
id: "src_notes",
name: "/vault/notes.md",
kind: "file",
createdAt: "2026-09-22T00:00:00.000Z",
updatedAt: "2026-09-22T00:00:00.000Z",
propositions: 1,
},
{
id: "chat1",
name: "Older session",
kind: "chat",
createdAt: "2026-09-20T00:00:00.000Z",
updatedAt: "2026-09-21T00:00:00.000Z",
propositions: 3,
},
{
id: "gone",
name: "gone",
kind: "file",
createdAt: null,
updatedAt: null,
propositions: 1,
},
], "most recent first; the undatable record sinks and names itself by id");
});
});
describe("graphSessionPropositions (the per-session view)", () => {
it("birth propositions — a pure list, no evaluations (2026-09-27 cleanup)", () => {
const nodes = [
node("a", "Alpha", [pt("chat1")]),
node("b", "Beta", [pt("chat1")]),
node("c", "Gamma", [pt("chat2")]), // born elsewhere — never listed under chat1
];
const deps = fakeStores(nodes);
assert.deepEqual(graphSessionPropositions(deps, "chat1"), [
{ nodeId: "a", name: "Alpha" },
{ nodeId: "b", name: "Beta" },
]);
});
});
// ─── imp-node cluster assignment (physics arc, owner 2026-09-30) ─────────────
describe("impClusterKey (majority among PREMISES' births)", () => {
const chainOf = (premises: string[], provenance: Array<{ chatId: string; messageId: string }> = []) =>
({ id: "arg_t", premises, conclusion: "c", provenance, createdAt: "2026-09-30T00:00:00.000Z" }) as GraphChain;
it("unanimous premises → that session", () => {
const key = impClusterKey(chainOf(["p1", "p2", "p3"]), () => "chatA");
assert.equal(key, "chatA");
});
it("cross-session majority wins (2 of 3 premises born in chatA)", () => {
const births: Record<string, string | null> = { p1: "chatA", p2: "chatA", p3: "chatB" };
const key = impClusterKey(chainOf(["p1", "p2", "p3"]), (id) => births[id] ?? null);
assert.equal(key, "chatA");
});
it("tie → the chain's OWN birth (the asserting session), not the conclusion's", () => {
const births: Record<string, string | null> = { p1: "chatA", p2: "chatB", c: "chatC" };
const key = impClusterKey(chainOf(["p1", "p2"], [pt("chatT")]), (id) => births[id] ?? null);
assert.equal(key, "chatT", "the chain's first provenance point breaks the tie");
});
it("no birth info at all → null is honest", () => {
assert.equal(impClusterKey(chainOf(["p1", "p2"]), () => null), null);
});
});
describe("attachImpToCommunities (the shared seam for pull + hulls)", () => {
it("a multi-premise live chain's imp id joins its home community, members stay sorted", () => {
const props = [node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3", "P3", [pt("chatA")])];
const communities = birthSessionPartition(props);
attachImpToCommunities(communities, props, [
attachable({ id: "arg_t", premises: ["p1", "p2"], conclusion: "p3", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
]);
assert.deepEqual(communities[0]!.members, ["arg_t", "p1", "p2", "p3"]);
});
it("single-premise chains attach NOTHING (no imp node exists — the collapse)", () => {
const props = [node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")])];
const communities = birthSessionPartition(props);
attachImpToCommunities(communities, props, [
attachable({ id: "arg_t", premises: ["p1"], conclusion: "p2", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
]);
assert.deepEqual(communities[0]!.members, ["p1", "p2"]);
});
it("dangling chains mint nothing; a home community ABSENT from the list stays unattached", () => {
const props = [
node("p1", "P1", [pt("chatA")]),
node("p1b", "P1b", [pt("chatA")]),
node("p1c", "P1c", [pt("chatA")]),
];
const elsewhere = [{ id: "chatB", members: ["q1"] }];
attachImpToCommunities(
elsewhere,
props,
[
// dangling conclusion — no imp node is minted, nothing to attach
attachable({ id: "arg_gone", premises: ["p1", "p1b"], conclusion: "ghost", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
// fully live, but its home community (chatA) is not in the
// list — the post-hull-filter case: stays unclustered
attachable({ id: "arg_home", premises: ["p1", "p1b"], conclusion: "p1c", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
],
);
assert.deepEqual(elsewhere[0]!.members, ["q1"]);
});
});
// ─── the snapshot seam: imp ids ride cluster members to BOTH consumers ──────
describe("graphSnapshot imp-node cluster assignment (physics arc, 2026-09-30)", () => {
const chats = [{ id: "chatA", title: "Session A", createdAt: "2026-09-30T00:00:00.000Z", updatedAt: "2026-09-30T00:00:00.000Z" }];
const multi = (id: string, premises: string[], conclusion: string, chatId = "chatA"): GraphChain => ({
id, premises, conclusion, provenance: [pt(chatId)], createdAt: "2026-09-30T00:00:00.000Z",
});
it("a live multi-premise chain's imp id JOINS the cluster's members (pull + hull read this list)", () => {
const snap = graphSnapshot(
fakeStores(
[node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3", "P3", [pt("chatA")])],
{ chats, chains: [multi("arg_t", ["p1", "p2"], "p3")] },
),
);
assert.equal(snap.clusters.length, 1);
assert.ok(snap.clusters[0]!.members.includes("arg_t"), "the imp node is a member — centroid pull AND hull rasterization consume members");
});
it("the hull filter counts PROPOSITIONS — attaching an imp never resurrects a 2-member hull", () => {
// p1,p2 born chatA; the conclusion born chatB. The chain is fully
// live, its premises-majority home is chatA — a 2-proposition
// community the hull filter drops; the imp id must NOT tip it to 3.
const snap = graphSnapshot(
fakeStores(
[node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3b", "P3b", [pt("chatB")])],
{ chats, chains: [multi("arg_t", ["p1", "p2"], "p3b")] },
),
);
assert.equal(snap.clusters.length, 0, "two propositions + a bundle is still not a hull");
});
});