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.
This commit is contained in:
amosblomqvist
2026-09-30 04:52:27 +03:00
parent 9f64019087
commit 35d8bc1a0c
9 changed files with 162 additions and 24 deletions
+4
View File
@@ -127,6 +127,9 @@ export interface CanvasData {
* drawn edges (legacy). Optional so non-graph callers of the * drawn edges (legacy). Optional so non-graph callers of the
* renderer stay simple. */ * renderer stay simple. */
logicalEdges?: CanvasEdge[]; logicalEdges?: CanvasEdge[];
/** The imp chain linkage — the depth pass anchors each junction at
* the premises/conclusion midpoint (owner, 2026-09-30). */
impLinks?: ReadonlyArray<{ id: string; premises: ReadonlyArray<string>; conclusion: string }>;
clusters: CanvasCluster[]; clusters: CanvasCluster[];
colors: CanvasColors; colors: CanvasColors;
/** Body font family (labels must match the app typography). */ /** Body font family (labels must match the app typography). */
@@ -1310,6 +1313,7 @@ export class GraphCanvasRenderer {
const t = this.physicsTuning; const t = this.physicsTuning;
this.sim = createGraphSimulation(data.nodes, data.edges, { this.sim = createGraphSimulation(data.nodes, data.edges, {
logicalEdges: data.logicalEdges, logicalEdges: data.logicalEdges,
impLinks: data.impLinks,
gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity }, gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity },
alphaDecay: LIVE_ALPHA_DECAY, alphaDecay: LIVE_ALPHA_DECAY,
velocityDecay: t.velocityDecay, velocityDecay: t.velocityDecay,
+1
View File
@@ -1383,6 +1383,7 @@ export function GraphView() {
})), })),
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })), edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
logicalEdges: snapshot.logicalEdges.map((e) => ({ from: e.from, to: e.to })), logicalEdges: snapshot.logicalEdges.map((e) => ({ from: e.from, to: e.to })),
impLinks: snapshot.impLinks,
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })), clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
colors: { colors: {
node: tokenHex(NODE_COLOR_TOKEN), node: tokenHex(NODE_COLOR_TOKEN),
+4
View File
@@ -33,6 +33,7 @@ function main() {
const view = deriveView(graph); const view = deriveView(graph);
const edges = view.edges; const edges = view.edges;
const logicalEdges = view.logicalEdges; const logicalEdges = view.logicalEdges;
const impLinks = view.impLinks;
const viewNodeById = new Map(view.nodes.map((n) => [n.id, n])); const viewNodeById = new Map(view.nodes.map((n) => [n.id, n]));
const chains = graph.chains; const chains = graph.chains;
// An implication node is delivered the moment its chain is complete // An implication node is delivered the moment its chain is complete
@@ -112,6 +113,9 @@ function main() {
...(depthMode === "radial" ? { baseRadius: 220 } : {}), ...(depthMode === "radial" ? { baseRadius: 220 } : {}),
}, },
logicalEdges: activeLogical, logicalEdges: activeLogical,
// A junction's anchor lights up exactly when the
// chain is delivered (its members all arrived).
impLinks: impLinks.filter((l) => delivered.has(l.id)),
}), }),
}); });
for (const [id, prev] of before) { for (const [id, prev] of before) {
+5
View File
@@ -319,6 +319,11 @@ export interface GraphSnapshot {
* depth pass consumes these — inference depth is a property of * depth pass consumes these — inference depth is a property of
* propositions, never of the geometry point (owner, 2026-09-30). */ * propositions, never of the geometry point (owner, 2026-09-30). */
logicalEdges: GraphEdge[]; logicalEdges: GraphEdge[];
/** DERIVED imp linkage (one per MULTI-premise live chain): the depth
* pass anchors each junction at the average of the highest premise
* row and the conclusion's row (owner, 2026-09-30) — strictly
* above the premises, strictly below the conclusion. */
impLinks: Array<{ id: string; premises: string[]; conclusion: string }>;
/** The STORED reasoning chains (merged by (premises, conclusion) at /** The STORED reasoning chains (merged by (premises, conclusion) at
* read): the chain table's and the practice pipeline's input — they * read): the chain table's and the practice pipeline's input — they
* read the truth, never the derived fan-out. */ * read the truth, never the derived fan-out. */
+12 -2
View File
@@ -316,7 +316,12 @@ export function getGraph(graphStore: Pick<GraphStore, "readNodes" | "readChains"
* regular citizens (half link distance — layout.ts). Dangling chains * regular citizens (half link distance — layout.ts). Dangling chains
* (a member node missing) drop here — the deletion cascades should * (a member node missing) drop here — the deletion cascades should
* prevent them; this is the honest belt. */ * prevent them; this is the honest belt. */
export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: GraphEdge[]; logicalEdges: GraphEdge[] } { export function deriveView(graph: DerivedGraph): {
nodes: GraphNode[];
edges: GraphEdge[];
logicalEdges: GraphEdge[];
impLinks: Array<{ id: string; premises: string[]; conclusion: string }>;
} {
const names: Record<string, { name: string }> = {}; const names: Record<string, { name: string }> = {};
for (const node of graph.nodes) names[node.id] = { name: node.name }; for (const node of graph.nodes) names[node.id] = { name: node.name };
const live = new Set(graph.nodes.map((node) => node.id)); const live = new Set(graph.nodes.map((node) => node.id));
@@ -326,6 +331,7 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr
// direct premise→conclusion arrows over PROPOSITION ids only — // direct premise→conclusion arrows over PROPOSITION ids only —
// what inference semantics (the depth pass) consumes. The drawn edges // what inference semantics (the depth pass) consumes. The drawn edges
// are the canvas image; these are the meaning. // are the canvas image; these are the meaning.
const impLinks: Array<{ id: string; premises: string[]; conclusion: string }> = [];
const logicalEdges: GraphEdge[] = []; const logicalEdges: GraphEdge[] = [];
const logicalSeen = new Set<string>(); const logicalSeen = new Set<string>();
const emitLogical = (from: string, to: string): void => { const emitLogical = (from: string, to: string): void => {
@@ -351,10 +357,13 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr
createdAt: chain.createdAt, createdAt: chain.createdAt,
provenance: [...chain.provenance], provenance: [...chain.provenance],
}); });
// The junction's linkage — what the depth pass needs for the
// midpoint anchor (owner, 2026-09-30).
impLinks.push({ id: chain.id, premises: [...chain.premises], conclusion: chain.conclusion });
for (const premise of chain.premises) edges.push({ from: premise, to: chain.id }); for (const premise of chain.premises) edges.push({ from: premise, to: chain.id });
edges.push({ from: chain.id, to: chain.conclusion }); edges.push({ from: chain.id, to: chain.conclusion });
} }
return { nodes: [...graph.nodes, ...impNodes], edges, logicalEdges }; return { nodes: [...graph.nodes, ...impNodes], edges, logicalEdges, impLinks };
} }
// ─── the /api/graph payload ────────────────────────────────────────────────── // ─── the /api/graph payload ──────────────────────────────────────────────────
@@ -442,6 +451,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
propositions: graph.nodes, propositions: graph.nodes,
edges: view.edges, edges: view.edges,
logicalEdges: view.logicalEdges, logicalEdges: view.logicalEdges,
impLinks: view.impLinks,
chains: graph.chains, chains: graph.chains,
sessions, sessions,
fileSources, fileSources,
+1
View File
@@ -48,6 +48,7 @@ export function settleGraphLayout(graphStore: {
const next = incrementalLayout(view.nodes, view.edges, previous, { const next = incrementalLayout(view.nodes, view.edges, previous, {
clusters: communities, clusters: communities,
logicalEdges: view.logicalEdges, logicalEdges: view.logicalEdges,
impLinks: view.impLinks,
}); });
graphStore.writePositions(Object.fromEntries(next)); graphStore.writePositions(Object.fromEntries(next));
} }
+58 -13
View File
@@ -182,6 +182,14 @@ export interface SimOptions {
* rank pass (owner, 2026-09-30). Absent = `edges` (legacy * rank pass (owner, 2026-09-30). Absent = `edges` (legacy
* behavior). Springs ALWAYS run over `edges` — the drawn arrows. */ * behavior). Springs ALWAYS run over `edges` — the drawn arrows. */
logicalEdges?: ReadonlyArray<LayoutEdge>; logicalEdges?: ReadonlyArray<LayoutEdge>;
/** The derived imp nodes' chain linkage (id + members), straight
* from deriveView. Drives the IMP MIDPOINT ANCHOR (owner,
* 2026-09-30): an implication node's depth is the average of the
* HIGHEST premise row and the conclusion's row — strictly above
* every premise, strictly below the conclusion, uniform in vertical
* (a row) and radial (a ring) modes. Absent = imp nodes keep the
* legacy orphan exemption (never depth-anchored). */
impLinks?: ReadonlyArray<ImpLink>;
/** Cluster centroid pull (the "virtual node" without the node): /** Cluster centroid pull (the "virtual node" without the node):
* members of each cluster accelerate toward their own cluster's * members of each cluster accelerate toward their own cluster's
* centroid. Two O(n) passes per tick — cheaper than a real virtual * centroid. Two O(n) passes per tick — cheaper than a real virtual
@@ -200,6 +208,15 @@ export interface LayoutEdge {
to: string; to: string;
} }
/** A derived implication node's chain linkage — what the depth pass needs
* to anchor the junction between its premises and their conclusion. */
export interface ImpLink {
/** The imp node's id (arg_-prefixed — the stored chain's id). */
id: string;
premises: ReadonlyArray<string>;
conclusion: string;
}
export const LINK_DISTANCE = 40; export const LINK_DISTANCE = 40;
/** Implication-node id prefix (the mint law lives in core/graph.ts's /** Implication-node id prefix (the mint law lives in core/graph.ts's
* newGraphChainId). Layout-side: links TOUCHING an imp node get HALF * newGraphChainId). Layout-side: links TOUCHING an imp node get HALF
@@ -301,6 +318,7 @@ export function createGraphSimulation(
// structure is inference over propositions, not the drawn geometry. // structure is inference over propositions, not the drawn geometry.
const depthEdges = options.logicalEdges ?? edges; const depthEdges = options.logicalEdges ?? edges;
let orphan: Uint8Array | null = null; let orphan: Uint8Array | null = null;
let depthsComputed: Map<string, number> | null = null;
if (mode !== "off") { if (mode !== "off") {
const connected = new Set<string>(); const connected = new Set<string>();
const valid = new Set(nodes.map((n) => n.id)); const valid = new Set(nodes.map((n) => n.id));
@@ -311,15 +329,42 @@ export function createGraphSimulation(
connected.add(e.from); connected.add(e.from);
connected.add(e.to); connected.add(e.to);
} }
// Proposition ranks first; then the IMP MIDPOINT ANCHOR (owner,
// 2026-09-30): an implication node's depth = (highest premise row
// + conclusion row) / 2 — the junction sits strictly above every
// premise and strictly below the conclusion (a conclusion is
// always ≥ highest premise + 1), uniform in both modes: a row in
// vertical, a ring in radial. Imp ids WITHOUT a supplied link
// keep the legacy exemption.
depthsComputed = inferenceDepths(
nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)),
depthEdges,
);
const anchored = new Set<string>();
if (options.impLinks) {
for (const link of options.impLinks) {
const conclusionDepth = depthsComputed.get(link.conclusion);
if (conclusionDepth === undefined || link.premises.length === 0) continue;
let highest = -Infinity;
let complete = true;
for (const premise of link.premises) {
const d = depthsComputed.get(premise);
if (d === undefined) {
complete = false;
break;
}
if (d > highest) highest = d;
}
if (!complete) continue;
depthsComputed.set(link.id, (highest + conclusionDepth) / 2);
anchored.add(link.id);
}
}
orphan = new Uint8Array(nodes.length); orphan = new Uint8Array(nodes.length);
nodes.forEach((n, i) => { nodes.forEach((n, i) => {
// IMP EXEMPTION joins the mask (owner, 2026-09-30): depth is a // IMP nodes with a midpoint anchor are STRUCTURED (they sit on
// property of inference over PROPOSITIONS; an implication node // their half-row); unlinked arg_ ids keep the orphan exemption.
// is pure geometry — never pinned to a level, keeps the if (!connected.has(n.id) && !(n.id.startsWith(IMP_ID_PREFIX) && anchored.has(n.id))) orphan![i] = 1;
// regular forces (cluster pull, springs, and gravity —
// horizontal-only in vertical mode, like everyone), 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; const g = options.gravity;
@@ -422,12 +467,9 @@ export function createGraphSimulation(
// organic sag. // organic sag.
const da = options.depthAnchor; const da = options.depthAnchor;
if (da && da.mode !== "off" && da.strength > 0) { if (da && da.mode !== "off" && da.strength > 0) {
// Propositions only: imp ids are absent from the rank map by // The rank map (propositions + imp midpoint anchors) was computed
// construction (belt under the orphan-mask exemption). // with the orphan mask above — one pass, shared.
const depths = inferenceDepths( const depths = depthsComputed!;
nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)),
depthEdges,
);
const strength = da.strength; const strength = da.strength;
const spacing = da.spacing; const spacing = da.spacing;
const c = da.center ?? { x: 0, y: 0 }; const c = da.center ?? { x: 0, y: 0 };
@@ -487,6 +529,8 @@ export interface IncrementalLayoutOptions {
depthAnchor?: DepthAnchor; depthAnchor?: DepthAnchor;
/** The LOGICAL edge list for the depth pass (see SimOptions). */ /** The LOGICAL edge list for the depth pass (see SimOptions). */
logicalEdges?: ReadonlyArray<LayoutEdge>; logicalEdges?: ReadonlyArray<LayoutEdge>;
/** The imp chain linkage for the midpoint anchor (see SimOptions). */
impLinks?: ReadonlyArray<ImpLink>;
} }
/** /**
@@ -626,6 +670,7 @@ export function incrementalLayout(
? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } } ? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } }
: options.depthAnchor, : options.depthAnchor,
logicalEdges: options.logicalEdges, logicalEdges: options.logicalEdges,
impLinks: options.impLinks,
}); });
const dMode = options.depthAnchor?.mode ?? "off"; const dMode = options.depthAnchor?.mode ?? "off";
const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy }; const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy };
+8
View File
@@ -1079,4 +1079,12 @@ describe("deriveView (the argument's geometry)", () => {
// Proposition ids only — no arg_ endpoint ever rides the logical set. // Proposition ids only — no arg_ endpoint ever rides the logical set.
assert.ok(view.logicalEdges.every((e) => !e.from.startsWith("arg_") && !e.to.startsWith("arg_"))); assert.ok(view.logicalEdges.every((e) => !e.from.startsWith("arg_") && !e.to.startsWith("arg_")));
}); });
it("impLinks: one linkage per MULTI-premise chain — the depth pass's midpoint input", () => {
const view = deriveView({
nodes: [prop("a"), prop("b"), prop("c")],
chains: [chain("arg_ab", ["a", "b"], "c"), chain("arg_ac", ["a"], "c")],
});
assert.deepEqual(view.impLinks, [{ id: "arg_ab", premises: ["a", "b"], conclusion: "c" }]);
});
}); });
+69 -9
View File
@@ -244,7 +244,7 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () =>
assert.ok(Math.abs(pos.get("lonely")!.y - 400) < 5, `lonely y free (got ${pos.get("lonely")!.y.toFixed(1)})`); 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)", () => { 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 mk = (id: string, x: number, y: number) => ({ id, x, y });
const sim = createGraphSimulation( const sim = createGraphSimulation(
[mk("a", 0, 0), mk("b", 40, 0), mk("c", 0, -480), mk("arg_x", 20, -100)], [mk("a", 0, 0), mk("b", 40, 0), mk("c", 0, -480), mk("arg_x", 20, -100)],
@@ -260,26 +260,86 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () =>
{ from: "a", to: "c" }, { from: "a", to: "c" },
{ from: "b", 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 }, depthAnchor: { mode: "vertical", strength: 1, spacing: 500 },
gravity: { x: 0, y: 0, strength: 0.1 }, gravity: { x: 0, y: 0, strength: 0.1 },
chargeStrength: 0, 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, linkStrength: 0.05,
}, },
); );
for (let i = 0; i < 400; i++) sim.tick(); 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])); 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 // Propositions pin to their rows: a/b at depth 0 (y≈0), c at
// depth 1 (y≈-500 — targetY = baseY − depth·spacing). // 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("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("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)})`); 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 // The junction pins EXACTLY halfway: (0 + 1)/2 = 0.5 → y≈−250 —
// pinned it to −500 as a depth-1 "node"); springs alone place // strictly above the premises, strictly below the conclusion.
// it between the levels. 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)})`);
assert.ok(Math.abs(pos.get("arg_x")!.y) < 250, `arg_x unpinned, floats between levels (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)})`);
}); });
}); });