diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index b8a1732..534f766 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -127,6 +127,9 @@ export interface CanvasData { * drawn edges (legacy). Optional so non-graph callers of the * renderer stay simple. */ 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; conclusion: string }>; clusters: CanvasCluster[]; colors: CanvasColors; /** Body font family (labels must match the app typography). */ @@ -1310,6 +1313,7 @@ export class GraphCanvasRenderer { const t = this.physicsTuning; this.sim = createGraphSimulation(data.nodes, data.edges, { logicalEdges: data.logicalEdges, + impLinks: data.impLinks, gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity }, alphaDecay: LIVE_ALPHA_DECAY, velocityDecay: t.velocityDecay, diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 61562f0..f3a8303 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -1383,6 +1383,7 @@ export function GraphView() { })), edges: snapshot.edges.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 })), colors: { node: tokenHex(NODE_COLOR_TOKEN), diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index ac24e7f..883938c 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -33,6 +33,7 @@ function main() { const view = deriveView(graph); const edges = view.edges; const logicalEdges = view.logicalEdges; + const impLinks = view.impLinks; const viewNodeById = new Map(view.nodes.map((n) => [n.id, n])); const chains = graph.chains; // An implication node is delivered the moment its chain is complete @@ -112,6 +113,9 @@ function main() { ...(depthMode === "radial" ? { baseRadius: 220 } : {}), }, 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) { diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 03a2983..f8337c0 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -319,6 +319,11 @@ export interface GraphSnapshot { * depth pass consumes these — inference depth is a property of * propositions, never of the geometry point (owner, 2026-09-30). */ 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 * read): the chain table's and the practice pipeline's input — they * read the truth, never the derived fan-out. */ diff --git a/src/core/graph.ts b/src/core/graph.ts index 9406c9a..655d0a5 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -316,7 +316,12 @@ export function getGraph(graphStore: Pick; +} { const names: Record = {}; for (const node of graph.nodes) names[node.id] = { name: node.name }; 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 — // what inference semantics (the depth pass) consumes. The drawn edges // are the canvas image; these are the meaning. + const impLinks: Array<{ id: string; premises: string[]; conclusion: string }> = []; const logicalEdges: GraphEdge[] = []; const logicalSeen = new Set(); const emitLogical = (from: string, to: string): void => { @@ -351,10 +357,13 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr createdAt: chain.createdAt, 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 }); 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 ────────────────────────────────────────────────── @@ -442,6 +451,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { propositions: graph.nodes, edges: view.edges, logicalEdges: view.logicalEdges, + impLinks: view.impLinks, chains: graph.chains, sessions, fileSources, diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts index 1156265..ae4b63b 100644 --- a/src/core/layout-settle.ts +++ b/src/core/layout-settle.ts @@ -48,6 +48,7 @@ export function settleGraphLayout(graphStore: { const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities, logicalEdges: view.logicalEdges, + impLinks: view.impLinks, }); graphStore.writePositions(Object.fromEntries(next)); } diff --git a/src/core/layout.ts b/src/core/layout.ts index 0686dc6..33a8463 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -182,6 +182,14 @@ export interface SimOptions { * rank pass (owner, 2026-09-30). Absent = `edges` (legacy * behavior). Springs ALWAYS run over `edges` — the drawn arrows. */ logicalEdges?: ReadonlyArray; + /** 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; /** Cluster centroid pull (the "virtual node" without the node): * members of each cluster accelerate toward their own cluster's * centroid. Two O(n) passes per tick — cheaper than a real virtual @@ -200,6 +208,15 @@ export interface LayoutEdge { 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; + conclusion: string; +} + export const LINK_DISTANCE = 40; /** Implication-node id prefix (the mint law lives in core/graph.ts's * 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. const depthEdges = options.logicalEdges ?? edges; let orphan: Uint8Array | null = null; + let depthsComputed: Map | null = null; if (mode !== "off") { const connected = new Set(); const valid = new Set(nodes.map((n) => n.id)); @@ -311,15 +329,42 @@ export function createGraphSimulation( connected.add(e.from); 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(); + 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); 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 - // 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; + // IMP nodes with a midpoint anchor are STRUCTURED (they sit on + // their half-row); unlinked arg_ ids keep the orphan exemption. + if (!connected.has(n.id) && !(n.id.startsWith(IMP_ID_PREFIX) && anchored.has(n.id))) orphan![i] = 1; }); } const g = options.gravity; @@ -422,12 +467,9 @@ export function createGraphSimulation( // organic sag. const da = options.depthAnchor; if (da && da.mode !== "off" && da.strength > 0) { - // Propositions only: imp ids are absent from the rank map by - // construction (belt under the orphan-mask exemption). - const depths = inferenceDepths( - nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)), - depthEdges, - ); + // The rank map (propositions + imp midpoint anchors) was computed + // with the orphan mask above — one pass, shared. + const depths = depthsComputed!; const strength = da.strength; const spacing = da.spacing; const c = da.center ?? { x: 0, y: 0 }; @@ -487,6 +529,8 @@ export interface IncrementalLayoutOptions { depthAnchor?: DepthAnchor; /** The LOGICAL edge list for the depth pass (see SimOptions). */ logicalEdges?: ReadonlyArray; + /** The imp chain linkage for the midpoint anchor (see SimOptions). */ + impLinks?: ReadonlyArray; } /** @@ -626,6 +670,7 @@ export function incrementalLayout( ? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } } : options.depthAnchor, logicalEdges: options.logicalEdges, + impLinks: options.impLinks, }); const dMode = options.depthAnchor?.mode ?? "off"; const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy }; diff --git a/tests/graph.test.ts b/tests/graph.test.ts index b4be25c..fffb569 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -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" }]); + }); }); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 27b15fd..0a1108b 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -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)})`); }); });