Owner ruling (2026-09-30, straight from the live-testing loop): when the depth anchor is vertical, the central pole (center-of-mass gravity) should create forces on the x axis ONLY. Half of this was already true: structured propositions have had x-only gravity in vertical mode since the 2026-09-13 depth projections (the depth force owns y). The change extends it to the one branch that still pulled on both axes — the orphan mask (unstructured propositions, and since 02a58db the imp geometry points): in vertical mode everyone gets the x-only pull; the full regular pull survives only in radial mode (where the rings own the radius) and in mode-off (plain forceX/forceY). Why this is coherent: y in vertical mode belongs to the depth rows and to nothing else. A structured node sits on its row; an unstructured node has no row to sit on — and now no pole to sag toward either. Its y is free (charge/springs only), so orphans keep their seeded or warm-started height instead of piling onto the center line, and imp bundles settle purely by their springs between premise and conclusion levels. Amended the ORPHAN RULE comments to state the new law, plus the imp- exemption comment (gravity is horizontal-only in vertical mode "like everyone"). Radial and mode-off paths untouched. Tests: the ORPHAN RULE pin rewritten to the amended law (orphan's y untouched at its seed, x still converges on the pole — the old pin asserted y converging on gravity's center); new explicit pin in the logical-graph describe (lonely node: |x| < 50, |y − seed| < 5); the 02a58db exemption fixture's comment updated to match (springs alone place arg_x between levels).
431 lines
18 KiB
TypeScript
431 lines
18 KiB
TypeScript
/**
|
||
* 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 depth force EXEMPTS the imp node like an orphan (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: NOT stamped onto c's row (old behavior
|
||
// pinned it to −500 as a depth-1 "node"); springs alone place
|
||
// it between the levels.
|
||
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 }));
|
||
|
||
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)})`);
|
||
});
|
||
});
|