From 9f6401908798d8f1ac875a907327bbfee526de0e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 30 Sep 2026 04:36:31 +0300 Subject: [PATCH] physics: vertical mode's central pole is horizontal-only for everyone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/core/layout.ts | 29 ++++++++++++++++----------- tests/layout.test.ts | 47 +++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/core/layout.ts b/src/core/layout.ts index b958577..0686dc6 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -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(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. } diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 077a166..27b15fd 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -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", () => {