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:
@@ -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. */
|
||||
|
||||
+12
-2
@@ -316,7 +316,12 @@ export function getGraph(graphStore: Pick<GraphStore, "readNodes" | "readChains"
|
||||
* regular citizens (half link distance — layout.ts). Dangling chains
|
||||
* (a member node missing) drop here — the deletion cascades should
|
||||
* 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 }> = {};
|
||||
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<string>();
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+58
-13
@@ -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<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):
|
||||
* 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<string>;
|
||||
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<string, number> | null = null;
|
||||
if (mode !== "off") {
|
||||
const connected = new Set<string>();
|
||||
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<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);
|
||||
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<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,
|
||||
logicalEdges: options.logicalEdges,
|
||||
impLinks: options.impLinks,
|
||||
});
|
||||
const dMode = options.depthAnchor?.mode ?? "off";
|
||||
const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy };
|
||||
|
||||
Reference in New Issue
Block a user