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).
This commit is contained in:
@@ -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<GraphChain, "id" | "premises" | "conclusion" | "provenance"> => 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<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user