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
+8
View File
@@ -1079,4 +1079,12 @@ describe("deriveView (the argument's geometry)", () => {
// 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_")));
});
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)})`);
});
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 sim = createGraphSimulation(
[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: "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,
// 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).
// 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)})`);
// 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)})`);
});
});