physics: vertical mode's central pole is horizontal-only for everyone

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).
This commit is contained in:
amosblomqvist
2026-09-30 04:36:31 +03:00
parent af666aaa3f
commit 9f64019087
2 changed files with 53 additions and 23 deletions
+18 -11
View File
@@ -291,7 +291,11 @@ export function createGraphSimulation(
// 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).
// gravity + full cluster pull, unprojected). AMENDED (owner,
// 2026-09-30 physics follow-up): in VERTICAL mode the central pole is
// a HORIZONTAL force for everyone — structured and orphan/imp alike —
// y belongs to the depth rows and to nothing else; the full regular
// pull survives only in radial mode (where the rings own the radius).
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.
@@ -311,9 +315,10 @@ export function createGraphSimulation(
nodes.forEach((n, i) => {
// 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.
// is pure geometry — never pinned to a level, keeps the
// 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;
});
}
@@ -323,19 +328,21 @@ export function createGraphSimulation(
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.
// force × strength × alpha). VERTICAL (owner, 2026-09-30 physics
// follow-up): the central pole is horizontal-ONLY for EVERY node —
// structured (the depth force owns y) and orphan/imp alike (an
// unstructured node has no row to sit on and no pole to sag
// toward; its y is free). RADIAL: orphans keep the full regular
// pull; structured nodes get nothing (the rings own the radius).
const orphanMask = orphan!;
sim.force("gravity", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
if (orphanMask[i]) {
if (mode === "vertical") {
n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha;
} else 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.
}
+35 -12
View File
@@ -224,7 +224,27 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () =>
assert.equal(depths.get("c"), 1);
});
it("the depth force EXEMPTS the imp node like an orphan (full gravity, never pinned to a row)", () => {
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)],
@@ -256,9 +276,9 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () =>
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: pulled by full gravity toward the
// center, NOT stamped onto c's row (old behavior pinned it to
// −500 as a depth-1 "node").
// 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)})`);
});
});
@@ -363,27 +383,30 @@ describe("depthAnchor force", () => {
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)", () => {
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 from the baseline, so the two are distinguishable:
// a depth-stamped orphan would sit on the baseline; an exempt one
// follows gravity instead.
// 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: 0, 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 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)})`);
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", () => {