Orphan discs in regions + orphan exemption from depth anchoring

Two follow-ups on the semantic-cluster wave, both user-reported:

1. Region coloring: edge-only dilation (the anti-double-counting rule
   from the Louvain era) left 114 of 338 cluster members completely
   uncolored — semantic clusters contain members that share no
   intra-cluster edge with anyone (90 edge-orphans + isolated
   micro-topics), and 20 of 45 clusters painted nothing at all. Fix:
   a disc (radius = dilation, same as a round cap) for exactly the
   members no intra-cluster edge stroke reached. Every member is
   painted by its edges' caps OR one disc, never both — the
   no-double-counting rule survives. Verified live: all 74
   orphans-in-clusters sit on tinted region pixels.

2. Depth modes (ORPHAN RULE): a node with no edges has no dependency
   structure, so depth-0 stamping put 90 unconnected propositions on
   the vertical baseline / radial base ring — meaning they don't have.
   Orphans are now exempt from the depth force and get the regular
   view's unprojected forces instead (full center gravity + full
   cluster pull). Structured-node projections unchanged (x-only
   gravity in vertical, none in radial; tangential-only cluster pull
   in radial). Gravity in depth modes is a custom force with EXACTLY
   d3's forceX/forceY math (strength × alpha — the first draft
   forgot the alpha and shifted the settle). Verified live: radial
   orphans follow their topics instead of the base ring; vertical
   orphans spread 1006 world units (6/90 near the baseline band vs
   90/90 before).
This commit is contained in:
amosblomqvist
2026-09-13 15:05:05 +03:00
parent f5edc7f0a5
commit 652a25e382
3 changed files with 120 additions and 8 deletions
+25 -4
View File
@@ -1205,10 +1205,9 @@ export class GraphCanvasRenderer {
});
// The region IS the dilated skeleton: intra-cluster edges as
// round-cap strokes (Minkowski dilation — points become discs at
// line ends via the caps). NO per-node discs: a cluster is its
// connected points, and discs double-counted where nodes sit on
// edges (user-identified hot spots).
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
// — they double-counted where nodes sit on edges (user-identified
// hot spots, when clusters were edge-based).
this.regionMeta = [];
data.clusters.forEach((cluster, ci) => {
const hue = clusterHue(cluster.id);
@@ -1217,6 +1216,8 @@ export class GraphCanvasRenderer {
let members = 0;
let sumX = 0;
let sumY = 0;
// Endpoints actually covered by a stroke's round caps.
const touched = new Set<string>();
for (const e of data.edges) {
if (clusterOf.get(e.from) !== ci) continue;
if (clusterOf.get(e.to) !== ci) continue;
@@ -1225,9 +1226,29 @@ export class GraphCanvasRenderer {
if (!a || !b) continue;
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
touched.add(e.from);
touched.add(e.to);
}
rctx.stroke();
// Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters
// are now topics by meaning, and many members (orphans, isolated
// micro-topics) share NO intra-cluster edge with anyone — edge
// strokes alone left them completely uncolored. A disc (same
// radius as a round cap = dilation) covers exactly the members no
// stroke reached: every member is painted by its edges' caps OR
// one disc, never both — the no-double-counting rule survives.
rctx.fillStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`;
const discR = (tuning.dilation * s) / cell;
for (const id of cluster.members) {
if (touched.has(id) || !isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
rctx.beginPath();
rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
rctx.fill();
}
// Label anchor: member centroid (world coords).
for (const id of cluster.members) {
if (!isVisible(id)) continue;
+53 -4
View File
@@ -262,11 +262,53 @@ export function createGraphSimulation(
// y axis, so gravity goes x-only (it would fight the level lines); in
// radial mode a center pull is purely radial and would fight the
// rings, so it is dropped entirely (rings already bound the radius).
// Depth mode projections (DECIDED 2026-09-13), with the ORPHAN RULE
// (user-decided, same day): a node with no edges at all has no
// dependency structure — depth is a property of inference chains, so
// stamping unconnected propositions onto the baseline / base ring
// assigns them meaning they don't have. Orphans are exempt from the
// depth force and keep the REGULAR view's forces instead (full center
// gravity + full cluster pull, unprojected).
const mode = options.depthAnchor?.mode ?? "off";
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) {
// 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;
connected.add(e.from);
connected.add(e.to);
}
orphan = new Uint8Array(nodes.length);
nodes.forEach((n, i) => {
if (!connected.has(n.id)) orphan![i] = 1;
});
}
const g = options.gravity;
if (g && mode !== "radial") {
if (g && mode === "off") {
sim.force("x", forceX<LayoutNode>(g.x).strength(g.strength));
if (mode !== "vertical") sim.force("y", forceY<LayoutNode>(g.y).strength(g.strength));
sim.force("y", forceY<LayoutNode>(g.y).strength(g.strength));
} else if (g) {
// Mode-projected gravity, EXACTLY d3's forceX/forceY math (restoring
// force × strength × alpha): structured nodes get x-only in vertical
// mode (the depth force owns y) and none in radial mode (the rings
// own the radius); ORPHANS get the full regular-view pull in every
// mode.
const orphanMask = orphan!;
sim.force("gravity", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
if (orphanMask[i]) {
n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha;
n.vy = (n.vy ?? 0) + (g.y - (n.y ?? 0)) * g.strength * alpha;
} else if (mode === "vertical") {
n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha;
}
// radial + structured: nothing — the rings own the radius.
}
});
}
// Cluster centroid pull: the membership index is resolved ONCE (nodes
@@ -311,7 +353,11 @@ export function createGraphSimulation(
const py = n.y ?? 0;
const ux = sumX[ci]! / cnt[ci]! - px;
const uy = sumY[ci]! / cnt[ci]! - py;
if (mode === "vertical") {
if (orphan && orphan[i]) {
// Orphans: full regular-view pull (see the ORPHAN RULE above).
n.vx = (n.vx ?? 0) + ux * strength * alpha;
n.vy = (n.vy ?? 0) + uy * strength * alpha;
} else if (mode === "vertical") {
n.vx = (n.vx ?? 0) + ux * strength * alpha;
} else if (mode === "radial") {
const rx = px - ringCx;
@@ -342,10 +388,12 @@ export function createGraphSimulation(
const strength = da.strength;
const spacing = da.spacing;
const c = da.center ?? { x: 0, y: 0 };
const orphanMask = orphan; // ORPHAN RULE: depth skips unstructured nodes
if (da.mode === "vertical") {
const baseY = c.y;
sim.force("depth", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
if (orphanMask?.[i]) continue;
const n = nodes[i]!;
const targetY = baseY - (depths.get(n.id) ?? 0) * spacing;
n.vy = (n.vy ?? 0) + (targetY - (n.y ?? 0)) * strength * alpha;
@@ -357,8 +405,9 @@ export function createGraphSimulation(
const cy0 = c.y;
sim.force("depth", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
if (orphanMask?.[i]) continue;
const n = nodes[i]!;
const dx = (n.x ?? 0) - cx0;
const dx = (n.x ?? 0) - cx0;
const dy = (n.y ?? 0) - cy0;
// Degenerate direction at the exact ring center: nudge
// outward on a deterministic axis.
+42
View File
@@ -270,4 +270,46 @@ describe("depthAnchor force", () => {
const child = next.get("child")!;
assert.ok(child.y < old.y - 40, `child rises above its premises (child=${child.y.toFixed(1)}, old=${old.y.toFixed(1)})`);
});
it("orphans are exempt from depth anchoring — regular forces govern them (ORPHAN RULE)", () => {
// A premise→conclusion chain plus an unconnected loner. Gravity's
// center is FAR from the baseline, so the two are distinguishable:
// a depth-stamped orphan would sit on the baseline; an exempt one
// follows gravity instead.
const ns = [
{ id: "base", x: 0, y: 0 },
{ id: "derived", x: 10, y: 0 },
{ id: "loner", x: 0, y: 0 },
];
const sim = createGraphSimulation(ns, [{ from: "base", to: "derived" }], {
gravity: { x: 0, y: 500, strength: 0.1 },
depthAnchor: { mode: "vertical", strength: 1.5, spacing: 120, center: { x: 0, y: 0 } },
});
for (let i = 0; i < 400; i++) sim.tick();
const byId = new Map(sim.nodes().map((n) => [n.id, n]));
assert.ok(Math.abs(byId.get("base")!.y!) < 40, "structured base sits on the baseline");
assert.ok(byId.get("derived")!.y! < -60, "derived rises a level");
const lonerY = byId.get("loner")!.y!;
assert.ok(lonerY > 250, `the orphan follows gravity, not the baseline (y=${lonerY.toFixed(0)})`);
assert.ok(Math.abs(lonerY - 500) < 120, `the orphan converges on gravity's center (y=${lonerY.toFixed(0)})`);
});
it("radial: orphans are exempt — they gather at the center, not on the base ring", () => {
const ns = [
{ id: "base", x: 0, y: 0 },
{ id: "mid", x: 10, y: 0 },
{ id: "loner", x: 300, y: 300 },
];
const sim = createGraphSimulation(ns, [{ from: "base", to: "mid" }], {
gravity: { x: 0, y: 0, strength: 0.1 },
depthAnchor: { mode: "radial", strength: 1.5, spacing: 80, baseRadius: 150, center: { x: 0, y: 0 } },
});
for (let i = 0; i < 400; i++) sim.tick();
const r = (id: string) => {
const n = sim.nodes().find((x) => x.id === id)!;
return Math.hypot(n.x!, n.y!);
};
assert.ok(Math.abs(r("base") - 150) < 40, `base sits on the base ring (r=${r("base").toFixed(0)})`);
assert.ok(r("loner") < 60, `the orphan follows gravity to the center, not the ring (r=${r("loner").toFixed(0)})`);
});
});