Files
amosblomqvist 35d8bc1a0c physics: imp nodes get the MIDPOINT depth anchor between premises and conclusion
Owner ruling (2026-09-30, live-testing loop — supersedes 02a58db's
exemption): implication nodes ARE depth-anchored after all. Their anchor
is the average of the HIGHEST premise row and the conclusion's row —
depth(imp) = (max(premise depths) + depth(conclusion)) / 2 — so the
junction always sits strictly above every premise and strictly below
the conclusion (a conclusion is >= highest premise + 1 in the logical
graph). Uniform across modes: a half-row in vertical, a ring midway in
radial (owner confirmed both).

Implementation:
- layout.ts: new ImpLink type; SimOptions/incrementalLayout gain
  impLinks (the derived chain linkage). The rank map is now computed
  once alongside the orphan mask: proposition ranks via Tarjan over the
  logical edges, then each supplied link adds its midpoint entry. An
  imp node WITH an anchor is structured (leaves the orphan mask —
  gravity projections treat it like any ranked node); WITHOUT supplied
  links, arg_ ids keep the legacy 02a58db exemption (honest fallback
  for callers that predate the field). The depth force reuses the
  precomputed map — one pass, shared.
- deriveView (core/graph.ts) emits impLinks (one per multi-premise
  live chain — single-premise chains mint no imp node); GraphSnapshot
  carries them; graph-view maps them into CanvasData; settleGraphLayout
  and replay-layout forward them (replay activates a junction's link
  exactly when its chain is delivered).

Tests (+3, 1 rewritten): the 02a58db exemption pin became the MIDPOINT
pin (premises row 0, conclusion row -500, junction pinned to -250);
mixed-depth pin (premises at 0 and 1, conclusion 2, junction at the 1.5
half-row — highest premise, not average-of-premises); legacy fallback
pin (no impLinks = never depth-anchored); deriveView impLinks emission
pin (multi-premise only). The horizontal-pole and ORPHAN RULE pins from
5554c3a survive unchanged — proposition orphans are untouched.
2026-09-30 04:52:27 +03:00

491 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Option A — incremental anchored force layout (core/layout.ts). The
* properties the user asked for: the layout EVOLVES (old placements stay
* recognizable), new nodes land near their cluster, orphans collect on
* the rim, and everything is deterministic.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts";
import { settleGraphLayout } from "../src/core/layout-settle.ts";
import type { GraphChain, GraphNode, Position } from "../src/core/contracts.ts";
const node = (id: string): GraphNode => ({
id,
name: id,
statement: id,
aliases: [],
createdAt: "2026-09-13T00:00:00.000Z",
provenance: [],
});
const chain = (id: string, premises: string[], conclusion: string): GraphChain => ({ id, premises, conclusion, provenance: [], createdAt: "2026-09-13T00:00:00.000Z" });
/** incrementalLayout's link shape — the DERIVED untyped arrow. */
const edge = (from: string, to: string): { from: string; to: string } => ({ from, to });
const mean = (positions: Map<string, Position>, ids: string[]): { x: number; y: number } => ({
x: ids.reduce((s, id) => s + positions.get(id)!.x, 0) / ids.length,
y: ids.reduce((s, id) => s + positions.get(id)!.y, 0) / ids.length,
});
describe("incrementalLayout", () => {
it("bootstraps deterministically from an empty ledger", () => {
const nodes = ["a", "b", "c", "d"].map(node);
const edges = [edge("a", "b"), edge("b", "c"), edge("c", "d")];
const first = incrementalLayout(nodes, edges, new Map());
const second = incrementalLayout(nodes, edges, new Map());
assert.equal(first.size, 4);
for (const [id, p] of first) {
assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `finite coords for ${id}`);
assert.deepEqual(p, second.get(id), `deterministic position for ${id}`);
}
});
it("rest state is stable: a re-settle with nothing new barely moves nodes", () => {
const nodes = ["a", "b", "c", "d", "e"].map(node);
const edges = [edge("a", "b"), edge("b", "c"), edge("c", "d"), edge("d", "e"), edge("a", "e")];
const rest = incrementalLayout(nodes, edges, new Map());
const span =
Math.max(...[...rest.values()].map((p) => p.x)) - Math.min(...[...rest.values()].map((p) => p.x));
const again = incrementalLayout(nodes, edges, rest);
let maxShift = 0;
for (const [id, p] of rest) {
const q = again.get(id)!;
maxShift = Math.max(maxShift, Math.hypot(p.x - q.x, p.y - q.y));
}
assert.ok(maxShift < span * 0.1, `re-settle drift ${maxShift.toFixed(2)} should be < 10% of span ${span.toFixed(2)}`);
});
it("evolves: an anchored node stays near its anchor while a new neighbor joins it", () => {
const core = ["a", "b", "c"].map(node);
const coreEdges = [edge("a", "b"), edge("b", "c")];
const previous = incrementalLayout(core, coreEdges, new Map());
const anchor = previous.get("b")!;
const dist = Math.hypot;
const nearby = ["d", "e"].map(node);
const next = incrementalLayout(
[...core, ...nearby],
[...coreEdges, edge("b", "d"), edge("d", "e")],
previous,
);
// The anchor keeps its neighborhood (bounded drift), and the new
// nodes landed near it rather than being flung elsewhere.
const coreSpanBefore =
Math.max(...[...previous.values()].map((p) => p.x)) - Math.min(...[...previous.values()].map((p) => p.x));
assert.ok(
dist(next.get("b")!.x - anchor.x, next.get("b")!.y - anchor.y) < coreSpanBefore,
"anchored node b stays near its previous position",
);
const centroid = mean(next, ["b", "d", "e"]);
assert.ok(
dist(next.get("d")!.x - centroid.x, next.get("d")!.y - centroid.y) < coreSpanBefore * 2,
"new neighbor d landed near the cluster it arrived with",
);
});
it("rim: an orphan ends up outside the anchored core", () => {
const core = ["a", "b", "c", "d", "e"].map(node);
const coreEdges = [
edge("a", "b"),
edge("b", "c"),
edge("c", "d"),
edge("d", "e"),
edge("a", "e"),
edge("b", "d"),
];
const previous = incrementalLayout(core, coreEdges, new Map());
const next = incrementalLayout([...core, node("orphan")], coreEdges, previous);
const c = mean(previous, ["a", "b", "c", "d", "e"]);
const coreMax = Math.max(
...[...next.entries()].filter(([id]) => id !== "orphan").map(([, p]) => Math.hypot(p.x - c.x, p.y - c.y)),
);
const orphanR = Math.hypot(next.get("orphan")!.x - c.x, next.get("orphan")!.y - c.y);
assert.ok(orphanR > coreMax, `orphan at r=${orphanR.toFixed(1)} should sit outside core max r=${coreMax.toFixed(1)}`);
});
});
describe("settleGraphLayout", () => {
it("dedupes repeated node lines, runs, and writes the whole ledger", () => {
const written: Record<string, Position>[] = [];
const lines = [node("a"), node("b"), node("c"), node("d"), node("a")]; // "a" appended twice
const store = {
readNodes: () => lines.map((l) => ({ ...l })),
readChains: () => [chain("arg_ab", ["a"], "b"), chain("arg_bcd", ["b", "c"], "d")],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p: Record<string, Position>) => void written.push(p),
};
settleGraphLayout(store);
assert.equal(written.length, 1);
// arg_ab is single-premise → collapsed to a direct arrow, NO imp
// node; arg_bcd is multi-premise → its imp node joins the layout.
assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "arg_bcd", "b", "c", "d"], "positions for the unique nodes and ONLY the multi-premise chain's imp node");
});
it("does nothing on an empty graph", () => {
const written: Record<string, Position>[] = [];
settleGraphLayout({
readNodes: () => [],
readChains: () => [],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p) => void written.push(p),
});
assert.equal(written.length, 0);
});
});
describe("imp-link half distance (rendering arc, owner 2026-09-30)", () => {
const settle = (from: string, to: string, linkDistance: number) => {
const nodes = [{ id: from, x: 0, y: 0 }, { id: to, x: 500, y: 0 }];
const sim = createGraphSimulation(nodes, [{ from, to }], {
linkDistance,
chargeStrength: 0, // springs only — the rest length IS the equilibrium
gravity: { x: 0, y: 0, strength: 0 },
});
for (let i = 0; i < 300; i++) sim.tick();
const [a, b] = sim.nodes() as unknown as { id: string; x: number; y: number }[];
assert.ok(a && b, "both nodes present");
return Math.abs(b.x - a.x);
};
it("a plain proposition link settles at the full rest length (tuned or default)", () => {
assert.ok(Math.abs(settle("a", "b", 100) - 100) < 2, "plain link ≈ 100");
});
it("a link TOUCHING an implication node gets HALF the rest length", () => {
// into the imp point (premise → arg_x)
assert.ok(Math.abs(settle("a", "arg_x", 100) - 50) < 2, "into-imp link ≈ 50");
// out of it (arg_x → conclusion)
assert.ok(Math.abs(settle("arg_x", "b", 100) - 50) < 2, "out-of-imp link ≈ 50");
});
});
describe("clusterPull (the centroid force)", () => {
it("compacts each cluster toward its OWN centroid; strength 0 = off", () => {
// Two loose chains, far apart, edges only WITHIN each chain.
const mk = (p: string, baseX: number) =>
Array.from({ length: 8 }, (_, i) => ({ id: `${p}${i}`, x: baseX + i * 40, y: i * 15 }));
const a = mk("a", -400);
const b = mk("b", 400);
const nodes = [...a, ...b];
const edges = [
...a.slice(0, -1).map((n, i) => ({ from: n.id, to: a[i + 1]!.id })),
...b.slice(0, -1).map((n, i) => ({ from: n.id, to: b[i + 1]!.id })),
];
const clusters = [{ members: a.map((n) => n.id) }, { members: b.map((n) => n.id) }];
const spreadOf = (ns: { id: string; x: number; y: number }[]): number => {
let sum = 0;
for (const group of [ns.filter((n) => n.id[0] === "a"), ns.filter((n) => n.id[0] === "b")]) {
const cx = group.reduce((s, n) => s + n.x, 0) / group.length;
const cy = group.reduce((s, n) => s + n.y, 0) / group.length;
sum += group.reduce((s, n) => s + Math.hypot(n.x - cx, n.y - cy), 0) / group.length;
}
return sum;
};
const run = (strength: number) => {
const sim = createGraphSimulation(
nodes.map((n) => ({ ...n })),
edges,
{ clusterPull: { clusters, strength } },
);
for (let i = 0; i < 150; i++) sim.tick();
return spreadOf(sim.nodes() as unknown as { id: string; x: number; y: number }[]);
};
const pulled = run(0.15);
const unpulled = run(0);
// The force compacts (well under the unpulled baseline)...
assert.ok(pulled < unpulled * 0.8, `pulled spread ${pulled.toFixed(1)} should be well under ${unpulled.toFixed(1)}`);
// ...and both groups survive as separate blobs (no merge).
assert.ok(pulled > 0, "spread is finite/positive");
});
});
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("VERTICAL mode: the central pole is horizontal-ONLY for everyone — orphans included (owner, 2026-09-30)", () => {
const mk = (id: string, x: number, y: number) => ({ id, x, y });
const sim = createGraphSimulation(
[mk("a", 0, 0), mk("b", 40, 0), mk("lonely", 500, 400)],
[{ from: "a", to: "b" }],
{
depthAnchor: { mode: "vertical", strength: 1, spacing: 60 },
gravity: { x: 0, y: 0, strength: 0.1 },
chargeStrength: 0,
},
);
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]));
// The orphan converges on x (the pole is still a pole)...
assert.ok(Math.abs(pos.get("lonely")!.x) < 50, `lonely x pulled to center (got ${pos.get("lonely")!.x.toFixed(1)})`);
// ...but its y is UNTOUCHED — no row to sit on, no sag toward the
// pole (old behavior dragged it to y≈0).
assert.ok(Math.abs(pos.get("lonely")!.y - 400) < 5, `lonely y free (got ${pos.get("lonely")!.y.toFixed(1)})`);
});
it("the IMP MIDPOINT ANCHOR: the junction pins halfway between premises and conclusion (owner, 2026-09-30)", () => {
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" },
],
// The junction's linkage: premises a,b (row 0), conclusion
// c (row 1) → the anchor is the AVERAGE, 0.5.
impLinks: [{ id: "arg_x", premises: ["a", "b"], conclusion: "c" }],
depthAnchor: { mode: "vertical", strength: 1, spacing: 500 },
gravity: { x: 0, y: 0, strength: 0.1 },
chargeStrength: 0,
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 junction pins EXACTLY halfway: (0 + 1)/2 = 0.5 → y≈−250 —
// strictly above the premises, strictly below the conclusion.
assert.ok(Math.abs(pos.get("arg_x")!.y + 250) < 40, `arg_x on the half-row (got ${pos.get("arg_x")!.y.toFixed(1)})`);
});
it("mixed-depth premises: the HIGHEST premise row is the premises side of the average", () => {
const mk = (id: string, x: number, y: number) => ({ id, x, y });
const sim = createGraphSimulation(
[mk("a", 0, 0), mk("b", 10, 0), mk("c", 0, 0), mk("arg_x", 5, 0)],
[
{ from: "a", to: "arg_x" },
{ from: "b", to: "arg_x" },
{ from: "arg_x", to: "c" },
],
{
// a→b→c ladder: b at depth 1, c at depth 2; the chain's
// premises are a (0) and b (1), conclusion c (2).
logicalEdges: [
{ from: "a", to: "b" },
{ from: "a", to: "c" },
{ from: "b", to: "c" },
],
impLinks: [{ id: "arg_x", premises: ["a", "b"], conclusion: "c" }],
depthAnchor: { mode: "vertical", strength: 1, spacing: 400 },
gravity: { x: 0, y: 0, strength: 0.1 },
chargeStrength: 0,
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]));
assert.ok(Math.abs(pos.get("a")!.y) < 40, `a on row 0 (got ${pos.get("a")!.y.toFixed(1)})`);
assert.ok(Math.abs(pos.get("b")!.y + 400) < 40, `b on row 1 (got ${pos.get("b")!.y.toFixed(1)})`);
assert.ok(Math.abs(pos.get("c")!.y + 800) < 40, `c on row 2 (got ${pos.get("c")!.y.toFixed(1)})`);
// (highest premise 1 + conclusion 2) / 2 = 1.5 → y≈−600.
assert.ok(Math.abs(pos.get("arg_x")!.y + 600) < 40, `arg_x on the 1.5 half-row (got ${pos.get("arg_x")!.y.toFixed(1)})`);
});
it("WITHOUT impLinks the imp node keeps the legacy exemption (never depth-anchored)", () => {
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)],
[
{ from: "a", to: "arg_x" },
{ from: "b", to: "arg_x" },
{ from: "arg_x", to: "c" },
],
{
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,
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]));
assert.ok(Math.abs(pos.get("c")!.y + 500) < 50, "c on row 1");
// No linkage supplied: no half-row, no stamping — springs alone
// place it between the levels.
assert.ok(Math.abs(pos.get("arg_x")!.y) < 250, `arg_x unanchored (got ${pos.get("arg_x")!.y.toFixed(1)})`);
});
});
describe("inferenceDepths (SCC-safe ranking)", () => {
const nodes = (ids: string[]) => ids.map((id) => ({ id }));
it("ranks a chain, a diamond takes the longest path, orphans are depth 0", () => {
// chain a->b->c; diamond a->d, b->d, c->d (d reachable at 1 and 2 and 3)
const ns = nodes(["a", "b", "c", "d", "loner"]);
const edges = [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "a", to: "d" },
{ from: "b", to: "d" },
{ from: "c", to: "d" },
];
const d = inferenceDepths(ns, edges);
assert.equal(d.get("a"), 0);
assert.equal(d.get("b"), 1);
assert.equal(d.get("c"), 2);
assert.equal(d.get("d"), 3); // longest path, not shortest
assert.equal(d.get("loner"), 0);
});
it("cycles share a depth (mutual support = equal footing)", () => {
// x <-> y (a cycle), fed by s; z follows x.
const ns = nodes(["s", "x", "y", "z"]);
const edges = [
{ from: "s", to: "x" },
{ from: "x", to: "y" },
{ from: "y", to: "x" },
{ from: "x", to: "z" },
];
const d = inferenceDepths(ns, edges);
assert.equal(d.get("s"), 0);
assert.equal(d.get("x"), d.get("y"), "cycle members share a depth");
assert.equal(d.get("x"), 1);
assert.equal(d.get("z"), 2);
// Deterministic across calls.
assert.deepEqual([...inferenceDepths(ns, edges).entries()], [...d.entries()]);
});
});
describe("depthAnchor force", () => {
const nodes = (ids: string[]) => ids.map((id) => ({ id, x: 0, y: 0 }));
it("vertical: the premise lands below its conclusion", () => {
const ns = nodes(["base", "derived"]);
const edges = [{ from: "base", to: "derived" }];
const sim = createGraphSimulation(ns, edges, {
depthAnchor: { mode: "vertical", strength: 1.2, spacing: 100, center: { x: 0, y: 0 } },
});
for (let i = 0; i < 300; i++) sim.tick();
const byId = new Map(sim.nodes().map((n) => [n.id, n]));
const baseY = byId.get("base")!.y!;
const derivedY = byId.get("derived")!.y!;
assert.ok(baseY > derivedY, `base (${baseY.toFixed(0)}) should sit BELOW derived (${derivedY.toFixed(0)})`);
assert.ok(
baseY - derivedY > 60,
`level separation should be substantial (got ${(baseY - derivedY).toFixed(0)})`,
);
});
it("radial: inference depth becomes radius", () => {
const ns = nodes(["base", "mid", "top"]);
const edges = [
{ from: "base", to: "mid" },
{ from: "mid", to: "top" },
];
const sim = createGraphSimulation(ns, edges, {
depthAnchor: { mode: "radial", strength: 1.2, spacing: 80, baseRadius: 100, center: { x: 0, y: 0 } },
});
for (let i = 0; i < 300; 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(r("base") < r("mid"), `base ring (${r("base").toFixed(0)}) inside mid (${r("mid").toFixed(0)})`);
assert.ok(r("mid") < r("top"), `mid ring (${r("mid").toFixed(0)}) inside top (${r("top").toFixed(0)})`);
});
it("settle with depthAnchor: old nodes keep x memory but snap to their level", () => {
// Node "old" was previously placed at y=300 (off-level). With
// vertical anchoring a re-settle must move it to the baseline
// while preserving (roughly) its x arrangement.
const previous = new Map([["old", { x: 250, y: 300 }], ["peer", { x: 260, y: 290 }]]);
const next = incrementalLayout(
[{ id: "old" }, { id: "peer" }, { id: "child" }],
[
{ from: "old", to: "child" },
{ from: "peer", to: "child" },
],
previous,
{ depthAnchor: { mode: "vertical", strength: 0.6, spacing: 80 } },
);
const old = next.get("old")!;
// The baseline defaults to the anchor centroid: (300+290)/2 = 295.
assert.ok(Math.abs(old.y - 295) < 20, `old node snaps to the baseline (y=${old.y.toFixed(1)})`);
assert.ok(old.x > 150, `x memory preserved (x=${old.x.toFixed(1)})`);
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 — y is FREE, the pole is horizontal (ORPHAN RULE, amended 2026-09-30)", () => {
// A premise→conclusion chain plus an unconnected loner. Gravity's
// center is FAR off-axis, so the forces are distinguishable: a
// depth-stamped orphan would sit on the baseline; the pre-amend
// exempt one sagged to gravity's y. Since the owner's 2026-09-30
// amendment the vertical pole is horizontal-ONLY for everyone —
// the orphan keeps its y and follows gravity on x alone.
const ns = [
{ id: "base", x: 0, y: 0 },
{ id: "derived", x: 10, y: 0 },
{ id: "loner", x: 400, y: -80 },
];
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 } },
chargeStrength: 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 loner = byId.get("loner")!;
assert.ok(Math.abs(loner.y! - (-80)) < 5, `the orphan's y is untouched (y=${loner.y!.toFixed(0)})`);
assert.ok(Math.abs(loner.x!) < 60, `the orphan still converges on the pole horizontally (x=${loner.x!.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)})`);
});
});