diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index e648b23..b8a1732 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -122,6 +122,11 @@ export interface CanvasColors { export interface CanvasData { nodes: CanvasNode[]; edges: CanvasEdge[]; + /** The LOGICAL arrows (proposition→conclusion, imp contracted) the + * depth force consumes — absent = the sim derives depth from the + * drawn edges (legacy). Optional so non-graph callers of the + * renderer stay simple. */ + logicalEdges?: CanvasEdge[]; clusters: CanvasCluster[]; colors: CanvasColors; /** Body font family (labels must match the app typography). */ @@ -1304,6 +1309,7 @@ export class GraphCanvasRenderer { } const t = this.physicsTuning; this.sim = createGraphSimulation(data.nodes, data.edges, { + logicalEdges: data.logicalEdges, gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity }, alphaDecay: LIVE_ALPHA_DECAY, velocityDecay: t.velocityDecay, diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b8f370f..61562f0 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -1382,6 +1382,7 @@ export function GraphView() { label: n.name, })), edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })), + logicalEdges: snapshot.logicalEdges.map((e) => ({ from: e.from, to: e.to })), clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })), colors: { node: tokenHex(NODE_COLOR_TOKEN), diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index e3822a3..ac24e7f 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -32,6 +32,7 @@ function main() { const nodes = graph.nodes; // getGraph already merges lines by id const view = deriveView(graph); const edges = view.edges; + const logicalEdges = view.logicalEdges; const viewNodeById = new Map(view.nodes.map((n) => [n.id, n])); const chains = graph.chains; // An implication node is delivered the moment its chain is complete @@ -91,6 +92,7 @@ function main() { // Edges activate once BOTH endpoints exist (a later file can claim // one endpoint — the edge lights up then). const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to)); + const activeLogical = logicalEdges.filter((e) => delivered.has(e.from) && delivered.has(e.to)); const before = new Map(positions); const batchNodes = view.nodes.filter((n) => delivered.has(n.id)); @@ -109,6 +111,7 @@ function main() { spacing: 60, ...(depthMode === "radial" ? { baseRadius: 220 } : {}), }, + logicalEdges: activeLogical, }), }); for (const [id, prev] of before) { diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 2ccb2b8..03a2983 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -314,6 +314,11 @@ export interface GraphSnapshot { /** DERIVED untyped arrows: premise → imp → conclusion. A lossy image * of the stored chains — rebuilt after every change, never stored. */ edges: GraphEdge[]; + /** DERIVED LOGICAL arrows: every chain contracted to direct premise→ + * conclusion over PROPOSITION ids only (the imp hop collapsed). The + * depth pass consumes these — inference depth is a property of + * propositions, never of the geometry point (owner, 2026-09-30). */ + logicalEdges: GraphEdge[]; /** The STORED reasoning chains (merged by (premises, conclusion) at * read): the chain table's and the practice pipeline's input — they * read the truth, never the derived fan-out. */ diff --git a/src/core/graph.ts b/src/core/graph.ts index bb14669..9406c9a 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -56,7 +56,7 @@ import type { GraphSnapshot, } from "./contracts.ts"; import { MIN_CLUSTER_SIZE } from "./clusters.ts"; -import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts"; +import { attachImpToCommunities, birthSessionOf, birthSessionPartition } from "./session-clusters.ts"; import { formatChainLine } from "./practice.ts"; import type { ChatStore, GraphStore } from "./ports.ts"; @@ -316,14 +316,27 @@ export function getGraph(graphStore: Pick = {}; for (const node of graph.nodes) names[node.id] = { name: node.name }; const live = new Set(graph.nodes.map((node) => node.id)); const impNodes: GraphNode[] = []; const edges: GraphEdge[] = []; + // The LOGICAL edge set (owner, 2026-09-30): every chain contracted to + // direct premise→conclusion arrows over PROPOSITION ids only — + // what inference semantics (the depth pass) consumes. The drawn edges + // are the canvas image; these are the meaning. + const logicalEdges: GraphEdge[] = []; + const logicalSeen = new Set(); + const emitLogical = (from: string, to: string): void => { + const key = from + "\u0000" + to; + if (logicalSeen.has(key)) return; + logicalSeen.add(key); + logicalEdges.push({ from, to }); + }; for (const chain of graph.chains) { if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue; + for (const premise of chain.premises) emitLogical(premise, chain.conclusion); // Single premise: the argument IS a plain arrow — no geometry // point needed (the 1-in/1-out collapse; rendering arc, owner). if (chain.premises.length === 1) { @@ -341,7 +354,7 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr for (const premise of chain.premises) edges.push({ from: premise, to: chain.id }); edges.push({ from: chain.id, to: chain.conclusion }); } - return { nodes: [...graph.nodes, ...impNodes], edges }; + return { nodes: [...graph.nodes, ...impNodes], edges, logicalEdges }; } // ─── the /api/graph payload ────────────────────────────────────────────────── @@ -411,6 +424,13 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { label: sessions[community.id] ?? fileSources[community.id] ?? null, members: community.members, })); + // IMP-NODE CLUSTER ASSIGNMENT (owner, 2026-09-30): majority among the + // premises' birth sessions, ties to the chain's own birth — attached + // AFTER the hull filter so a two-proposition community + its bundle + // never resurrects a hull (the filter counts propositions). Both + // cluster-membership consumers — the sim's centroid pull and the + // canvas's region rasterization — read it through this one list. + attachImpToCommunities(clusters, graph.nodes, graph.chains); // Resting layout (Option A): the positions ledger, when the settle has // ever run. Absent on a fresh graph — the view falls back to its own @@ -421,6 +441,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { nodes, propositions: graph.nodes, edges: view.edges, + logicalEdges: view.logicalEdges, chains: graph.chains, sessions, fileSources, diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts index 04b1761..1156265 100644 --- a/src/core/layout-settle.ts +++ b/src/core/layout-settle.ts @@ -1,6 +1,6 @@ import type { GraphChain, GraphNode, Position } from "./contracts.ts"; import { incrementalLayout } from "./layout.ts"; -import { birthSessionPartition } from "./session-clusters.ts"; +import { attachImpToCommunities, birthSessionPartition } from "./session-clusters.ts"; import { deriveView, getGraph } from "./graph.ts"; /** @@ -39,6 +39,15 @@ export function settleGraphLayout(graphStore: { // PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never // partition; the stored world clusters, its image follows. const communities = birthSessionPartition(graph.nodes); - const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities }); + // The SAME imp-node assignment the snapshot renders (majority among + // premises' births — core/session-clusters.ts): the resting ledger and + // the live sim must agree on where bundles belong, or the first live + // tick would fight the settle. Unfiltered here (settle pulls every + // community, hulls are a canvas-only concern). + attachImpToCommunities(communities, graph.nodes, graph.chains); + const next = incrementalLayout(view.nodes, view.edges, previous, { + clusters: communities, + logicalEdges: view.logicalEdges, + }); graphStore.writePositions(Object.fromEntries(next)); } diff --git a/src/core/layout.ts b/src/core/layout.ts index 5d6e9eb..b958577 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -177,6 +177,11 @@ export interface SimOptions { velocityDecay?: number; /** Inference-depth anchoring (see DepthAnchor). Off by default. */ depthAnchor?: DepthAnchor; + /** The LOGICAL edge list (proposition→conclusion, imp hops + * contracted) for the depth machinery — orphan detection AND the + * rank pass (owner, 2026-09-30). Absent = `edges` (legacy + * behavior). Springs ALWAYS run over `edges` — the drawn arrows. */ + logicalEdges?: ReadonlyArray; /** Cluster centroid pull (the "virtual node" without the node): * members of each cluster accelerate toward their own cluster's * centroid. Two O(n) passes per tick — cheaper than a real virtual @@ -288,11 +293,14 @@ export function createGraphSimulation( // depth force and keep the REGULAR view's forces instead (full center // gravity + full cluster pull, unprojected). const mode = options.depthAnchor?.mode ?? "off"; + // The depth machinery reads the LOGICAL edge list when supplied — + // structure is inference over propositions, not the drawn geometry. + const depthEdges = options.logicalEdges ?? edges; let orphan: Uint8Array | null = null; if (mode !== "off") { const connected = new Set(); const valid = new Set(nodes.map((n) => n.id)); - for (const e of edges) { + for (const e of depthEdges) { // Self-loops and dangling endpoints are not a structure (same // guards as inferenceDepths). if (e.from === e.to || !valid.has(e.from) || !valid.has(e.to)) continue; @@ -301,7 +309,12 @@ export function createGraphSimulation( } orphan = new Uint8Array(nodes.length); nodes.forEach((n, i) => { - if (!connected.has(n.id)) orphan![i] = 1; + // IMP EXEMPTION joins the mask (owner, 2026-09-30): depth is a + // property of inference over PROPOSITIONS; an implication node + // is pure geometry — never pinned to a level, keeps the full + // regular forces (gravity, cluster pull, springs), and settles + // naturally between premise and conclusion levels. + if (!connected.has(n.id) || n.id.startsWith(IMP_ID_PREFIX)) orphan![i] = 1; }); } const g = options.gravity; @@ -402,7 +415,12 @@ export function createGraphSimulation( // organic sag. const da = options.depthAnchor; if (da && da.mode !== "off" && da.strength > 0) { - const depths = inferenceDepths(nodes, edges); + // Propositions only: imp ids are absent from the rank map by + // construction (belt under the orphan-mask exemption). + const depths = inferenceDepths( + nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)), + depthEdges, + ); const strength = da.strength; const spacing = da.spacing; const c = da.center ?? { x: 0, y: 0 }; @@ -460,6 +478,8 @@ export interface IncrementalLayoutOptions { /** Inference-depth anchoring (see DepthAnchor). `center` defaults to * the anchored core's centroid. */ depthAnchor?: DepthAnchor; + /** The LOGICAL edge list for the depth pass (see SimOptions). */ + logicalEdges?: ReadonlyArray; } /** @@ -598,6 +618,7 @@ export function incrementalLayout( options.depthAnchor && options.depthAnchor.mode !== "off" ? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } } : options.depthAnchor, + logicalEdges: options.logicalEdges, }); const dMode = options.depthAnchor?.mode ?? "off"; const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy }; diff --git a/src/core/session-clusters.ts b/src/core/session-clusters.ts index e59a3fb..ec828b5 100644 --- a/src/core/session-clusters.ts +++ b/src/core/session-clusters.ts @@ -32,7 +32,7 @@ * core barrel — the shell reads clusters from the snapshot; only the * server-side derivations (snapshot, settle) and tests import this. */ -import type { GraphNode } from "./contracts.ts"; +import type { GraphChain, GraphNode } from "./contracts.ts"; /** One birth-session cluster: the session id + the SORTED member node * ids born in it. The shape is deliberately the { members } contract @@ -47,7 +47,7 @@ export interface BirthSessionCluster { /** A node's birth session: its first surviving provenance point's chatId. * null when the node carries no provenance at all (legacy data). */ -export function birthSessionOf(node: GraphNode): string | null { +export function birthSessionOf(node: Pick): string | null { return node.provenance[0]?.chatId ?? null; } @@ -73,4 +73,58 @@ export function birthSessionPartition(nodes: readonly GraphNode[]): BirthSession return [...members.entries()] .map(([id, list]) => ({ id, members: [...list].sort() })) .sort((a, b) => b.members.length - a.members.length || (a.id < b.id ? -1 : 1)); -} \ No newline at end of file +} + +/** The cluster key a derived implication node takes: MAJORITY among its + * PREMISES' birth sessions (owner, 2026-09-30 — premises, NOT the + * conclusion), ties to the chain's OWN birth (its provenance's first + * point, the asserting session — deterministic, and it coincides with + * the unanimous common case anyway). Imp nodes are always re-derived, + * never birthed, so their cluster must be a function of the derivation + * — this is that function. */ +export function impClusterKey( + chain: Pick, + birthOf: (nodeId: string) => string | null, +): string | null { + const counts = new Map(); + for (const premise of chain.premises) { + const birth = birthOf(premise); + if (birth !== null) counts.set(birth, (counts.get(birth) ?? 0) + 1); + } + const own = chain.provenance[0]?.chatId ?? null; + if (counts.size === 0) return own; + let max = 0; + for (const n of counts.values()) if (n > max) max = n; + const winners: string[] = []; + for (const [key, n] of counts) if (n === max) winners.push(key); + if (winners.length === 1) return winners[0]!; + // Tie → the chain's own birth; if even that is missing, the + // alphabetically-first winner (last-resort determinism). + return own ?? winners.sort()[0]!; +} + +/** Attach derived imp nodes to communities IN PLACE (the shared seam for + * both consumers of cluster membership — the sim's centroid pull and + * the canvas's region rasterization both read members lists that went + * through here). Only MULTI-premise fully-live chains mint imp nodes + * (single-premise chains collapsed to direct arrows, rendering arc + * 2026-09-30); dangling chains mint nothing. A chain whose home + * community is absent from the list stays unclustered — like its + * premises when the hull filter drops them. */ +export function attachImpToCommunities( + communities: Array<{ id: string; members: string[] }>, + propositions: ReadonlyArray>, + chains: ReadonlyArray>, +): void { + const birthById = new Map(propositions.map((node) => [node.id, birthSessionOf(node)])); + const live = new Set(propositions.map((node) => node.id)); + for (const chain of chains) { + if (chain.premises.length < 2) continue; + if (!live.has(chain.conclusion) || !chain.premises.every((p) => live.has(p))) continue; + const key = impClusterKey(chain, (id) => birthById.get(id) ?? null); + const community = key === null ? undefined : communities.find((c) => c.id === key); + if (!community) continue; + community.members.push(chain.id); + } + for (const community of communities) community.members.sort(); +} diff --git a/src/server/graph.ts b/src/server/graph.ts index a86e525..d47ea1d 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -33,7 +33,10 @@ export function graphView(): GraphSnapshot { * honest, never guessed. A corrupt/empty graph is an empty list. */ export function graphClusters(): GraphClusterGroup[] { const snap = graphSnapshot(stores); - return birthSessionPartition(snap.nodes).map((group) => ({ + // PROPOSITIONS ONLY: snap.nodes is the canvas image (imp nodes ride + // it); the picker offers propositions — an arg_ id in nodeIds would + // silently vanish inside chainsForSelection and lie about nodeCount. + return birthSessionPartition(snap.propositions).map((group) => ({ key: group.id, kind: group.id in snap.sessions ? "chat" : "notes", label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null, diff --git a/tests/graph.test.ts b/tests/graph.test.ts index fa29b48..b4be25c 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -1062,4 +1062,21 @@ describe("deriveView (the argument's geometry)", () => { assert.deepEqual(view.nodes.map((n) => n.id), ["a"]); assert.deepEqual(view.edges, []); }); + + it("LOGICAL edges: every chain contracts to direct premise→conclusion arrows (all chains, deduped)", () => { + const view = deriveView({ + nodes: [prop("a"), prop("b"), prop("c")], + chains: [chain("arg_ab", ["a", "b"], "c"), chain("arg_ac", ["a"], "c")], + }); + // arg_ab: a→c AND b→c; arg_ac: a→c again → deduped to one arrow. + assert.deepEqual( + [...view.logicalEdges].sort((x, y) => (x.from < y.from ? -1 : x.from > y.from ? 1 : x.to < y.to ? -1 : 1)), + [ + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ], + ); + // Proposition ids only — no arg_ endpoint ever rides the logical set. + assert.ok(view.logicalEdges.every((e) => !e.from.startsWith("arg_") && !e.to.startsWith("arg_"))); + }); }); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 2e8a755..077a166 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -199,6 +199,70 @@ describe("clusterPull (the centroid force)", () => { }); }); +describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () => { + it("a multi-premise conclusion sits at premises'+1 — NOT +2 — over logical edges", () => { + const props = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const logical = [ + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ]; + const depths = inferenceDepths(props, logical); + assert.equal(depths.get("c"), 1, "one inference hop from BOTH premises — the imp point is not a hop"); + }); + + it("the imp node is ABSENT from the rank map (depth is a property of propositions)", () => { + const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "arg_x" }]; + const logical = [ + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ]; + const depths = inferenceDepths( + nodes.filter((n) => !n.id.startsWith("arg_")), + logical, + ); + assert.ok(!depths.has("arg_x"), "geometry points carry no inference rank"); + assert.equal(depths.get("c"), 1); + }); + + it("the depth force EXEMPTS the imp node like an orphan (full gravity, never pinned to a row)", () => { + const mk = (id: string, x: number, y: number) => ({ id, x, y }); + const sim = createGraphSimulation( + [mk("a", 0, 0), mk("b", 40, 0), mk("c", 0, -480), mk("arg_x", 20, -100)], + // Drawn edges: the imp fan (springs ride these). + [ + { from: "a", to: "arg_x" }, + { from: "b", to: "arg_x" }, + { from: "arg_x", to: "c" }, + ], + { + // The logical edges: what depth consumes. + logicalEdges: [ + { from: "a", to: "c" }, + { from: "b", to: "c" }, + ], + depthAnchor: { mode: "vertical", strength: 1, spacing: 500 }, + gravity: { x: 0, y: 0, strength: 0.1 }, + chargeStrength: 0, + // Weak springs: the rows pin crisply while the exempt imp + // node still floats (its signal is WHERE it isn't — on + // no row). + linkStrength: 0.05, + }, + ); + for (let i = 0; i < 400; i++) sim.tick(); + const pos = new Map((sim.nodes() as unknown as { id: string; x: number; y: number }[]).map((n) => [n.id, n])); + // Propositions pin to their rows: a/b at depth 0 (y≈0), c at + // depth 1 (y≈-500 — targetY = baseY − depth·spacing). + assert.ok(Math.abs(pos.get("a")!.y) < 50, `a on row 0 (got ${pos.get("a")!.y.toFixed(1)})`); + assert.ok(Math.abs(pos.get("b")!.y) < 50, `b on row 0 (got ${pos.get("b")!.y.toFixed(1)})`); + assert.ok(Math.abs(pos.get("c")!.y + 500) < 50, `c on row 1 (got ${pos.get("c")!.y.toFixed(1)})`); + // The imp node is exempt: pulled by full gravity toward the + // center, NOT stamped onto c's row (old behavior pinned it to + // −500 as a depth-1 "node"). + assert.ok(Math.abs(pos.get("arg_x")!.y) < 250, `arg_x unpinned, floats between levels (got ${pos.get("arg_x")!.y.toFixed(1)})`); + }); +}); + describe("inferenceDepths (SCC-safe ranking)", () => { const nodes = (ids: string[]) => ids.map((id) => ({ id })); diff --git a/tests/session-clusters.test.ts b/tests/session-clusters.test.ts index 01e0a60..e10ba48 100644 --- a/tests/session-clusters.test.ts +++ b/tests/session-clusters.test.ts @@ -19,11 +19,19 @@ import { graphSnapshot, type GraphDeps, } from "../src/core/graph.ts"; -import { birthSessionOf, birthSessionPartition } from "../src/core/session-clusters.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 => c; const node = (id: string, name: string, provenance: Array<{ chatId: string; messageId: string }>): GraphNode => ({ id, name, @@ -38,9 +46,10 @@ function fakeStores( over: { chats?: Array>; sources?: Array>; + chains?: GraphChain[]; } = {}, ): GraphDeps { - const chains: GraphChain[] = []; + const chains: GraphChain[] = over.chains ?? []; const graphStore: GraphStore = { readNodes: () => nodes, readChains: () => chains, @@ -241,4 +250,106 @@ describe("graphSessionPropositions (the per-session view)", () => { { nodeId: "b", name: "Beta" }, ]); }); -}); \ No newline at end of file +}); + +// ─── 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 = { 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 = { 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"); + }); +});