rendering arc: the imp node becomes the argument's geometry, not a fake proposition
Owner-approved brief (orchestrator): the canvas stops drawing implication nodes as first-class nodes and starts drawing them as what they are — the point where co-premise lines converge. - deriveView (core/graph.ts): single-premise chains collapse to ONE direct premise->conclusion arrow — no imp node minted (a chain is always N-in/ 1-out; one premise is the degenerate case). Multi-premise chains keep the imp node + fan. Dangling guard unchanged. - physics (core/layout.ts createGraphSimulation): per-link rest length — a link touching an arg_-prefixed endpoint gets HALF the base link distance, default or user-tuned alike; everything else about imp-node physics citizenship unchanged. - canvas (components/graph-canvas.ts): imp nodes are PURE GEOMETRY — invisible (no disc, no label) and unpickable (excluded from pickAt's hit-test projection). Edges INTO an imp node: headless, shaft runs to its CENTER (toRadius 0, no head triangle). Edges OUT: shaft starts at the center (fromRadius 0), arrowhead lands on the conclusion's disc edge. Single-premise direct arrows render as today's plain arrows. - comment repairs (same commit): the two stale double-shaft claims (CanvasEdge doc + the draw-pass block) rewritten to the truth — plain single shafts; bundles are headless converging lines + one arrowed out-edge; no double-shaft glyph exists anywhere. - tests (+9): deriveView pins (single-premise collapse, multi-premise fan, shared-conclusion distinctness, dangling drop); imp-link half- distance settle pins; arrowGeometry zero-radius laws + isImpNodeId pin; graph.test.ts's snapshot invariant now counts only MULTI-premise live chains (its fixture's single-premise chains mint zero imp nodes); layout settle fixture updated for the collapse.
This commit is contained in:
+34
-13
@@ -86,14 +86,23 @@ export interface CanvasNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One directed edge of the DERIVED view (chains-primary, 2026-09-30):
|
/** One directed edge of the DERIVED view (chains-primary, 2026-09-30):
|
||||||
* premise → implication node → conclusion, UNTYPED — every arrow is an
|
* premise → implication node → conclusion, UNTYPED, all PLAIN
|
||||||
* implication, so every arrow renders the double-shaft glyph (⟹) +
|
* single-shaft arrows — there is no double-shaft glyph anywhere
|
||||||
* arrowhead (DECIDED 2026-09-14, docs/vision-edge-glyph.md). */
|
* (the 2026-09-14 ruling died with typed edges). An argument renders
|
||||||
|
* as its geometry: co-premise lines converge HEADLESS onto the
|
||||||
|
* implication point, one arrowed line leaves it; a single-premise
|
||||||
|
* argument is one ordinary arrow (see deriveView). */
|
||||||
export interface CanvasEdge {
|
export interface CanvasEdge {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Implication-node ids (the mint law: newGraphChainId in core/graph.ts).
|
||||||
|
* On the canvas an imp node is PURE GEOMETRY (owner, rendering arc
|
||||||
|
* 2026-09-30): invisible — no disc, no label — and UNPICKABLE,
|
||||||
|
* excluded from every hit-test path. Exported for the test pin. */
|
||||||
|
export const isImpNodeId = (id: string): boolean => id.startsWith("arg_");
|
||||||
|
|
||||||
/** One cluster region: member node ids + the LLM-coined label (or null). */
|
/** One cluster region: member node ids + the LLM-coined label (or null). */
|
||||||
export interface CanvasCluster {
|
export interface CanvasCluster {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1048,6 +1057,7 @@ export class GraphCanvasRenderer {
|
|||||||
const projected: { id: string; x: number; y: number }[] = [];
|
const projected: { id: string; x: number; y: number }[] = [];
|
||||||
for (const n of this.data.nodes) {
|
for (const n of this.data.nodes) {
|
||||||
if (this.visible && !this.visible.has(n.id)) continue;
|
if (this.visible && !this.visible.has(n.id)) continue;
|
||||||
|
if (isImpNodeId(n.id)) continue; // pure geometry — never picked
|
||||||
projected.push({ id: n.id, x: n.x * k + tx, y: n.y * k + ty });
|
projected.push({ id: n.id, x: n.x * k + tx, y: n.y * k + ty });
|
||||||
}
|
}
|
||||||
const hit = pickNearest(projected, px, py, maxDist);
|
const hit = pickNearest(projected, px, py, maxDist);
|
||||||
@@ -1218,21 +1228,24 @@ export class GraphCanvasRenderer {
|
|||||||
const visible = this.visible;
|
const visible = this.visible;
|
||||||
const isVisible = (id: string) => !visible || visible.has(id);
|
const isVisible = (id: string) => !visible || visible.has(id);
|
||||||
|
|
||||||
// Node discs: one Path2D, one fill() per frame.
|
// Node discs: one Path2D, one fill() per frame. Implication nodes
|
||||||
|
// are pure geometry — never a disc, never a label, never picked.
|
||||||
const nodesPath = new Path2D();
|
const nodesPath = new Path2D();
|
||||||
for (const n of data.nodes) {
|
for (const n of data.nodes) {
|
||||||
if (!isVisible(n.id)) continue;
|
if (!isVisible(n.id) || isImpNodeId(n.id)) continue;
|
||||||
nodesPath.moveTo(n.x + nodeR, n.y);
|
nodesPath.moveTo(n.x + nodeR, n.y);
|
||||||
nodesPath.arc(n.x, n.y, nodeR, 0, Math.PI * 2);
|
nodesPath.arc(n.x, n.y, nodeR, 0, Math.PI * 2);
|
||||||
}
|
}
|
||||||
this.nodesPath = nodesPath;
|
this.nodesPath = nodesPath;
|
||||||
|
|
||||||
// Edge shafts + arrowheads: tip lands on the target disc edge. Both
|
// Edge shafts + arrowheads (rendering arc, owner 2026-09-30): all
|
||||||
// relation types draw the same arrow for now — the double-shaft
|
// arrows are PLAIN single shafts — the old double-shaft glyph is
|
||||||
// glyph was rejected (2026-09-15); differentiation is pending a new
|
// gone entirely. An argument's geometry: lines INTO an implication
|
||||||
// approach. Two owner rulings 2026-09-29: the supports draw toggle
|
// node are HEADLESS and run all the way to the imp node's CENTER
|
||||||
// CHAINS-PRIMARY (2026-09-30): one path family — every derived arrow
|
// (co-premise lines converge to a point); the line OUT of it starts
|
||||||
// is an implication, double-shafted + arrowheaded uniformly.
|
// at the center and its arrowhead lands on the conclusion's disc
|
||||||
|
// edge. Single-premise arguments arrive here as ordinary
|
||||||
|
// proposition→proposition edges and draw exactly like any arrow.
|
||||||
const arrowLen = settings.arrowLength;
|
const arrowLen = settings.arrowLength;
|
||||||
const shafts = new Path2D();
|
const shafts = new Path2D();
|
||||||
const heads = new Path2D();
|
const heads = new Path2D();
|
||||||
@@ -1241,15 +1254,23 @@ export class GraphCanvasRenderer {
|
|||||||
const a = this.nodeById.get(e.from);
|
const a = this.nodeById.get(e.from);
|
||||||
const b = this.nodeById.get(e.to);
|
const b = this.nodeById.get(e.to);
|
||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
const intoImp = isImpNodeId(e.to);
|
||||||
|
const outOfImp = isImpNodeId(e.from);
|
||||||
|
const g = arrowGeometry(a, b, outOfImp ? 0 : nodeR, intoImp ? 0 : nodeR, arrowLen);
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
||||||
|
if (intoImp) {
|
||||||
|
// toRadius 0 put the tip AT the imp node's center: the
|
||||||
|
// headless shaft terminates there (no head triangle).
|
||||||
|
shafts.lineTo(g.tip.x, g.tip.y);
|
||||||
|
} else {
|
||||||
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
||||||
heads.moveTo(g.tip.x, g.tip.y);
|
heads.moveTo(g.tip.x, g.tip.y);
|
||||||
heads.lineTo(g.left.x, g.left.y);
|
heads.lineTo(g.left.x, g.left.y);
|
||||||
heads.lineTo(g.right.x, g.right.y);
|
heads.lineTo(g.right.x, g.right.y);
|
||||||
heads.closePath();
|
heads.closePath();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.shaftsPath = shafts;
|
this.shaftsPath = shafts;
|
||||||
this.headsPath = heads;
|
this.headsPath = heads;
|
||||||
|
|
||||||
@@ -1266,7 +1287,7 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.font = this.worldLabelFont;
|
ctx.font = this.worldLabelFont;
|
||||||
const maxW = LABEL_MAX_WIDTH * (settings.labelSize / LABEL_SIZE);
|
const maxW = LABEL_MAX_WIDTH * (settings.labelSize / LABEL_SIZE);
|
||||||
for (const n of data.nodes) {
|
for (const n of data.nodes) {
|
||||||
if (isVisible(n.id)) this.labelLines.set(n.id, wrapLabel(ctx, n.label, maxW));
|
if (isVisible(n.id) && !isImpNodeId(n.id)) this.labelLines.set(n.id, wrapLabel(ctx, n.label, maxW));
|
||||||
}
|
}
|
||||||
ctx.font = prevFont;
|
ctx.font = prevFont;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-9
@@ -303,15 +303,19 @@ export function getGraph(graphStore: Pick<GraphStore, "readNodes" | "readChains"
|
|||||||
return { nodes: [...byId.values()], chains };
|
return { nodes: [...byId.values()], chains };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The DERIVED view — what the canvas renders. One implication NODE per
|
/** The DERIVED view — what the canvas renders. A SINGLE-premise chain
|
||||||
* chain (id = the chain's arg_ id; name "⟹"; statement = the chain
|
* collapses to ONE direct arrow premise → conclusion (no imp node:
|
||||||
* line; the chain's provenance/createdAt) plus UNTYPED arrows: each
|
* a chain is always N-in/1-out, so one premise is the degenerate
|
||||||
* premise → the implication node, the implication node → the
|
* 1-in-1-out case). A MULTI-premise chain mints an implication NODE
|
||||||
* conclusion. Rendering specialization (invisible / physics-free)
|
* (id = the chain's arg_ id; name "⟹"; statement = the chain line;
|
||||||
* comes later (owner, 2026-09-30: physics is fine with imp nodes as
|
* the chain's provenance/createdAt) plus arrows: each premise → the
|
||||||
* regular nodes for now). Dangling chains (a member node missing)
|
* implication node, the implication node → the conclusion — the
|
||||||
* drop here — the deletion cascades should prevent them; this is the
|
* canvas renders imp nodes as pure geometry (owner, rendering arc:
|
||||||
* honest belt. */
|
* invisible, unpickable; co-premise lines converge headless to a
|
||||||
|
* point, one arrowed line leaves it). Physics keeps imp nodes as
|
||||||
|
* 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[] } {
|
export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||||
const names: Record<string, { name: string }> = {};
|
const names: Record<string, { name: string }> = {};
|
||||||
for (const node of graph.nodes) names[node.id] = { name: node.name };
|
for (const node of graph.nodes) names[node.id] = { name: node.name };
|
||||||
@@ -320,6 +324,12 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr
|
|||||||
const edges: GraphEdge[] = [];
|
const edges: GraphEdge[] = [];
|
||||||
for (const chain of graph.chains) {
|
for (const chain of graph.chains) {
|
||||||
if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue;
|
if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue;
|
||||||
|
// Single premise: the argument IS a plain arrow — no geometry
|
||||||
|
// point needed (the 1-in/1-out collapse; rendering arc, owner).
|
||||||
|
if (chain.premises.length === 1) {
|
||||||
|
edges.push({ from: chain.premises[0]!, to: chain.conclusion });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
impNodes.push({
|
impNodes.push({
|
||||||
id: chain.id,
|
id: chain.id,
|
||||||
name: "⟹",
|
name: "⟹",
|
||||||
|
|||||||
+19
-1
@@ -196,6 +196,14 @@ export interface LayoutEdge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LINK_DISTANCE = 40;
|
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
|
||||||
|
* the base rest length — the argument's geometry (a converging point)
|
||||||
|
* sits tight against its premises/conclusion instead of spread like a
|
||||||
|
* real proposition citizen (owner ruling, rendering arc 2026-09-30). */
|
||||||
|
const IMP_ID_PREFIX = "arg_";
|
||||||
|
const impLinkId = (v: string | number | { id: string }): string =>
|
||||||
|
typeof v === "object" ? v.id : String(v);
|
||||||
export const CHARGE_STRENGTH = -240; // 2026-09-17: −120 → −240 (user: repulsion default too weak); the default is shared with render-settings (gm-centered in CHARGE_RANGE).
|
export const CHARGE_STRENGTH = -240; // 2026-09-17: −120 → −240 (user: repulsion default too weak); the default is shared with render-settings (gm-centered in CHARGE_RANGE).
|
||||||
/** Per-tick anchor re-blend: fraction of an anchored node's displacement
|
/** Per-tick anchor re-blend: fraction of an anchored node's displacement
|
||||||
* from its previous position that SURVIVES each tick. 0.85 = gentle
|
* from its previous position that SURVIVES each tick. 0.85 = gentle
|
||||||
@@ -238,6 +246,7 @@ export function createGraphSimulation(
|
|||||||
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
|
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
const linkStrength = options.linkStrength ?? 1;
|
const linkStrength = options.linkStrength ?? 1;
|
||||||
|
const linkDistance = options.linkDistance ?? LINK_DISTANCE;
|
||||||
|
|
||||||
const sim = forceSimulation<LayoutNode, SimLink>(nodes)
|
const sim = forceSimulation<LayoutNode, SimLink>(nodes)
|
||||||
.force(
|
.force(
|
||||||
@@ -246,7 +255,16 @@ export function createGraphSimulation(
|
|||||||
edges.map((e) => ({ source: e.from, target: e.to })),
|
edges.map((e) => ({ source: e.from, target: e.to })),
|
||||||
)
|
)
|
||||||
.id((d) => d.id)
|
.id((d) => d.id)
|
||||||
.distance(options.linkDistance ?? LINK_DISTANCE)
|
// Per-link rest length (rendering arc, owner 2026-09-30): a
|
||||||
|
// link touching an implication node (arg_-prefixed id — the
|
||||||
|
// string form pre-init, the node ref after) gets HALF the
|
||||||
|
// base distance, default or user-tuned alike.
|
||||||
|
.distance(
|
||||||
|
(l) =>
|
||||||
|
impLinkId(l.source).startsWith(IMP_ID_PREFIX) || impLinkId(l.target).startsWith(IMP_ID_PREFIX)
|
||||||
|
? linkDistance / 2
|
||||||
|
: linkDistance,
|
||||||
|
)
|
||||||
.strength(
|
.strength(
|
||||||
(l) =>
|
(l) =>
|
||||||
(linkStrength * 1) /
|
(linkStrength * 1) /
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { describe, it, it as test } from "node:test";
|
|||||||
import {
|
import {
|
||||||
applyClusterLabels,
|
applyClusterLabels,
|
||||||
arrowGeometry,
|
arrowGeometry,
|
||||||
|
isImpNodeId,
|
||||||
boxBlurRound,
|
boxBlurRound,
|
||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
@@ -148,6 +149,26 @@ describe("arrowGeometry", () => {
|
|||||||
test("null for coincident endpoints (never a NaN arrow)", () => {
|
test("null for coincident endpoints (never a NaN arrow)", () => {
|
||||||
assert.equal(arrowGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5), null);
|
assert.equal(arrowGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The rendering arc's geometry laws (owner, 2026-09-30): an edge INTO
|
||||||
|
// an imp point is drawn with toRadius 0 (headless, terminates at the
|
||||||
|
// center); an edge OUT of one with fromRadius 0 (starts at the center).
|
||||||
|
test("toRadius 0 — the tip lands exactly at the target CENTER (headless convergence)", () => {
|
||||||
|
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 0, 5)!;
|
||||||
|
assert.ok(Math.abs(g.tip.x - 100) < 1e-9 && Math.abs(g.tip.y) < 1e-9, "tip = the center, past which no shaft draws");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fromRadius 0 — the shaft starts exactly at the source CENTER", () => {
|
||||||
|
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 0, 6, 5)!;
|
||||||
|
assert.ok(Math.abs(g.shaftFrom.x) < 1e-9 && Math.abs(g.shaftFrom.y) < 1e-9, "shaftFrom = the center");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isImpNodeId (the pure-geometry law, rendering arc 2026-09-30)", () => {
|
||||||
|
test("arg_-prefixed ids are geometry; proposition ids are citizens", () => {
|
||||||
|
assert.equal(isImpNodeId("arg_123af5a046ba"), true);
|
||||||
|
assert.equal(isImpNodeId("n_abc123"), false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+77
-10
@@ -19,11 +19,11 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import type { AuditVerdicts, SessionFindings } from "../src/core/contracts.ts";
|
import type { AuditVerdicts, GraphChain, GraphNode, SessionFindings } from "../src/core/contracts.ts";
|
||||||
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { projectView as projectNative } from "../src/core/view.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
import { deriveView, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||||
import { coverageSlots } from "../src/core/coverage.ts";
|
import { coverageSlots } from "../src/core/coverage.ts";
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
@@ -459,18 +459,22 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path");
|
assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path");
|
||||||
|
|
||||||
// Chains-primary split (owner, 2026-09-30): `nodes` is the canvas
|
// Chains-primary split (owner, 2026-09-30): `nodes` is the canvas
|
||||||
// image (propositions + derived implication nodes), `propositions`
|
// image, `propositions` the stored truth alone (the chain table's
|
||||||
// is the stored truth alone — the chain table's input. The store is
|
// input). The store is shared across this suite, so assert the
|
||||||
// shared across this suite, so assert the INVARIANT, not counts:
|
// INVARIANT, not counts — and since the rendering arc
|
||||||
// image = truth + one imp node per fully-live stored chain, and the
|
// (2026-09-30), only MULTI-premise live chains mint imp nodes
|
||||||
// truth never carries an arg_ id.
|
// (this fixture's two single-premise chains collapse to direct
|
||||||
|
// arrows — zero imp nodes here).
|
||||||
const stored = graphStore.readChains();
|
const stored = graphStore.readChains();
|
||||||
const live = new Set(snap.propositions.map((n) => n.id));
|
const live = new Set(snap.propositions.map((n) => n.id));
|
||||||
const liveChains = stored.filter((c) => live.has(c.conclusion) && c.premises.every((p) => live.has(p)));
|
const impChains = stored.filter(
|
||||||
|
(c) => c.premises.length >= 2 && live.has(c.conclusion) && c.premises.every((p) => live.has(p)),
|
||||||
|
);
|
||||||
assert.ok(snap.propositions.every((n) => !n.id.startsWith("arg_")), "the stored-truth list never carries imp nodes");
|
assert.ok(snap.propositions.every((n) => !n.id.startsWith("arg_")), "the stored-truth list never carries imp nodes");
|
||||||
assert.equal(snap.nodes.length, snap.propositions.length + liveChains.length, "image = truth + one imp node per live chain");
|
assert.equal(snap.nodes.length, snap.propositions.length + impChains.length, "image = truth + one imp node per MULTI-premise live chain");
|
||||||
const imp = snap.nodes.filter((n) => n.id.startsWith("arg_"));
|
const imp = snap.nodes.filter((n) => n.id.startsWith("arg_"));
|
||||||
assert.ok(imp.length === liveChains.length && imp.every((n) => snap.propositions.every((p) => p.id !== n.id)), "the extra nodes are exactly the derived implication nodes");
|
assert.ok(imp.length === impChains.length && imp.every((n) => snap.propositions.every((p) => p.id !== n.id)), "the extra nodes are exactly the derived implication nodes");
|
||||||
|
assert.equal(imp.length, 0, "this fixture's chains are single-premise — all collapsed to direct arrows");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives the sessions index in graphSnapshot", () => {
|
it("derives the sessions index in graphSnapshot", () => {
|
||||||
@@ -996,3 +1000,66 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
|||||||
assert.equal(sessionCoverage("chat_never_was"), null);
|
assert.equal(sessionCoverage("chat_never_was"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── deriveView (the rendering arc, owner 2026-09-30) ────────────────────────
|
||||||
|
|
||||||
|
describe("deriveView (the argument's geometry)", () => {
|
||||||
|
const prop = (id: string): GraphNode => ({
|
||||||
|
id,
|
||||||
|
name: id.toUpperCase(),
|
||||||
|
statement: `${id} statement`,
|
||||||
|
aliases: [],
|
||||||
|
createdAt: "2026-09-30T00:00:00.000Z",
|
||||||
|
provenance: [],
|
||||||
|
});
|
||||||
|
const chain = (id: string, premises: string[], conclusion: string): GraphChain => ({
|
||||||
|
id,
|
||||||
|
premises,
|
||||||
|
conclusion,
|
||||||
|
provenance: [],
|
||||||
|
createdAt: "2026-09-30T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a SINGLE-premise chain collapses to ONE direct arrow — no imp node", () => {
|
||||||
|
const view = deriveView({ nodes: [prop("a"), prop("b")], chains: [chain("arg_ab", ["a"], "b")] });
|
||||||
|
assert.deepEqual(view.nodes.map((n) => n.id), ["a", "b"], "no arg_ node minted");
|
||||||
|
assert.deepEqual(view.edges, [{ from: "a", to: "b" }], "the argument IS a plain arrow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a MULTI-premise chain keeps the imp node + fan (premises in, conclusion out)", () => {
|
||||||
|
const view = deriveView({
|
||||||
|
nodes: [prop("a"), prop("b"), prop("c")],
|
||||||
|
chains: [chain("arg_ab", ["a", "b"], "c")],
|
||||||
|
});
|
||||||
|
assert.deepEqual(view.nodes.map((n) => n.id).sort(), ["a", "arg_ab", "b", "c"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...view.edges].sort((x, y) => (x.from < y.from ? -1 : x.from > y.from ? 1 : x.to < y.to ? -1 : 1)),
|
||||||
|
[
|
||||||
|
{ from: "a", to: "arg_ab" },
|
||||||
|
{ from: "arg_ab", to: "c" },
|
||||||
|
{ from: "b", to: "arg_ab" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const imp = view.nodes.find((n) => n.id === "arg_ab")!;
|
||||||
|
assert.equal(imp.name, "\u27f9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two chains SHARING a conclusion stay distinct — two geometry points, never a unioned fan", () => {
|
||||||
|
const view = deriveView({
|
||||||
|
nodes: [prop("a"), prop("b"), prop("c"), prop("d"), prop("e")],
|
||||||
|
chains: [chain("arg_e1", ["a", "b"], "e"), chain("arg_e2", ["c", "d"], "e")],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
view.nodes.map((n) => n.id).sort(),
|
||||||
|
["a", "arg_e1", "arg_e2", "b", "c", "d", "e"],
|
||||||
|
);
|
||||||
|
assert.ok(view.edges.some((x) => x.from === "arg_e1" && x.to === "e"));
|
||||||
|
assert.ok(view.edges.some((x) => x.from === "arg_e2" && x.to === "e"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dangling chains drop (missing member — the honest belt)", () => {
|
||||||
|
const view = deriveView({ nodes: [prop("a")], chains: [chain("arg_ghost", ["a"], "gone"), chain("arg_gone", ["ghost"], "a")] });
|
||||||
|
assert.deepEqual(view.nodes.map((n) => n.id), ["a"]);
|
||||||
|
assert.deepEqual(view.edges, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+31
-3
@@ -106,16 +106,18 @@ describe("incrementalLayout", () => {
|
|||||||
describe("settleGraphLayout", () => {
|
describe("settleGraphLayout", () => {
|
||||||
it("dedupes repeated node lines, runs, and writes the whole ledger", () => {
|
it("dedupes repeated node lines, runs, and writes the whole ledger", () => {
|
||||||
const written: Record<string, Position>[] = [];
|
const written: Record<string, Position>[] = [];
|
||||||
const lines = [node("a"), node("b"), node("a")]; // "a" appended twice
|
const lines = [node("a"), node("b"), node("c"), node("d"), node("a")]; // "a" appended twice
|
||||||
const store = {
|
const store = {
|
||||||
readNodes: () => lines.map((l) => ({ ...l })),
|
readNodes: () => lines.map((l) => ({ ...l })),
|
||||||
readChains: () => [chain("arg_ab", ["a"], "b")],
|
readChains: () => [chain("arg_ab", ["a"], "b"), chain("arg_bcd", ["b", "c"], "d")],
|
||||||
readPositions: () => ({} as Record<string, Position>),
|
readPositions: () => ({} as Record<string, Position>),
|
||||||
writePositions: (p: Record<string, Position>) => void written.push(p),
|
writePositions: (p: Record<string, Position>) => void written.push(p),
|
||||||
};
|
};
|
||||||
settleGraphLayout(store);
|
settleGraphLayout(store);
|
||||||
assert.equal(written.length, 1);
|
assert.equal(written.length, 1);
|
||||||
assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "arg_ab", "b"], "one position per unique node AND the derived implication node");
|
// arg_ab is single-premise → collapsed to a direct arrow, NO imp
|
||||||
|
// node; arg_bcd is multi-premise → its imp node joins the layout.
|
||||||
|
assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "arg_bcd", "b", "c", "d"], "positions for the unique nodes and ONLY the multi-premise chain's imp node");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing on an empty graph", () => {
|
it("does nothing on an empty graph", () => {
|
||||||
@@ -129,6 +131,32 @@ describe("settleGraphLayout", () => {
|
|||||||
assert.equal(written.length, 0);
|
assert.equal(written.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("imp-link half distance (rendering arc, owner 2026-09-30)", () => {
|
||||||
|
const settle = (from: string, to: string, linkDistance: number) => {
|
||||||
|
const nodes = [{ id: from, x: 0, y: 0 }, { id: to, x: 500, y: 0 }];
|
||||||
|
const sim = createGraphSimulation(nodes, [{ from, to }], {
|
||||||
|
linkDistance,
|
||||||
|
chargeStrength: 0, // springs only — the rest length IS the equilibrium
|
||||||
|
gravity: { x: 0, y: 0, strength: 0 },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 300; i++) sim.tick();
|
||||||
|
const [a, b] = sim.nodes() as unknown as { id: string; x: number; y: number }[];
|
||||||
|
assert.ok(a && b, "both nodes present");
|
||||||
|
return Math.abs(b.x - a.x);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("a plain proposition link settles at the full rest length (tuned or default)", () => {
|
||||||
|
assert.ok(Math.abs(settle("a", "b", 100) - 100) < 2, "plain link ≈ 100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a link TOUCHING an implication node gets HALF the rest length", () => {
|
||||||
|
// into the imp point (premise → arg_x)
|
||||||
|
assert.ok(Math.abs(settle("a", "arg_x", 100) - 50) < 2, "into-imp link ≈ 50");
|
||||||
|
// out of it (arg_x → conclusion)
|
||||||
|
assert.ok(Math.abs(settle("arg_x", "b", 100) - 50) < 2, "out-of-imp link ≈ 50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("clusterPull (the centroid force)", () => {
|
describe("clusterPull (the centroid force)", () => {
|
||||||
it("compacts each cluster toward its OWN centroid; strength 0 = off", () => {
|
it("compacts each cluster toward its OWN centroid; strength 0 = off", () => {
|
||||||
// Two loose chains, far apart, edges only WITHIN each chain.
|
// Two loose chains, far apart, edges only WITHIN each chain.
|
||||||
|
|||||||
Reference in New Issue
Block a user