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:
amosblomqvist
2026-09-30 04:30:25 +03:00
parent 719f7f9934
commit af666aaa3f
12 changed files with 330 additions and 15 deletions
+24 -3
View File
@@ -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<LayoutEdge>;
/** 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<string>();
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<LayoutEdge>;
}
/**
@@ -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 };