chains-primary: stored chains replace typed edges; edges become a derived view

Owner rulings 2026-09-30: reasoning chains are the stored truth; the graph
canvas renders a lossy derived image. No migration, no backcompat — old
graph data was wiped.

- contracts: GraphChain (arg_-minted id, premises, conclusion) replaces
  typed edges; GraphEdge survives only as the untyped derived arrow;
  GraphSnapshot carries stored chains; supports edges retired entirely
- store: chains.jsonl ledger (edges.jsonl purged at load alongside legacy
  events.jsonl); readChains/appendChain seam; merge dedupes by
  (sorted premises, conclusion), re-assertions append under the same arg_ id
- core: deriveView mints implication nodes (name ⟹, statement via
  formatChainLine) plus premise→imp→conclusion arrows; clusters stay
  proposition-only (birthSessionPartition); chainsForSelection now groups
  STORED chains (chain rides iff every member selected) — independent
  arguments for one conclusion never union; chainsToGraph/chainsToEdges die
- extraction: extractor submits chains natively (expandFindings deleted);
  auditor receives propositions only; supports fan-out gone
- deletion: full death of a proposition kills every referencing chain;
  arg_-prefixed node deletes kill that chain; chainsRemoved stats
- UI: every arrow renders the ⟹ double shaft; supports toggle + thickness
  slider and detail-card edge sections deleted (card keeps name +
  proposition); chain table + practice pipeline read stored chains
- tests: 1188 green across the new seams
This commit is contained in:
amosblomqvist
2026-09-30 03:22:52 +03:00
parent a3aabb3a57
commit 4985b6f9fa
28 changed files with 669 additions and 726 deletions
+10 -10
View File
@@ -68,7 +68,7 @@ import {
XIcon,
} from "lucide-react";
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
import type { GraphEdge, GraphNode } from "@/src/core";
import type { GraphChain, GraphNode } from "@/src/core";
import { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog";
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
@@ -99,7 +99,7 @@ interface ClusterGroup {
* via props; the overlay wrapper below owns it. */
export function ChainTableBody({
nodes,
edges,
chains: storedChains,
sessions,
fileSources,
query,
@@ -118,7 +118,7 @@ export function ChainTableBody({
onClose,
}: {
nodes: GraphNode[];
edges: GraphEdge[];
chains: GraphChain[];
sessions: Record<string, string>;
fileSources: Record<string, string>;
query: string;
@@ -140,7 +140,7 @@ export function ChainTableBody({
onClose: () => void;
}) {
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const chains = useMemo(() => chainsForSelection(nodes, edges), [nodes, edges]);
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
const searching = query.trim() !== "";
// The fuzzy filter: a chain is visible when ANY member matches on
@@ -223,8 +223,8 @@ export function ChainTableBody({
// The derived shape under the floating bar (the agents' ACTUAL input).
const derived = useMemo(() => {
const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
return chainsForSelection(selectedNodes, edges);
}, [selection, byId, edges]);
return chainsForSelection(selectedNodes, storedChains);
}, [selection, byId, storedChains]);
const nameOf = (id: string) => byId.get(id)?.name ?? id;
const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? "";
@@ -583,14 +583,14 @@ function RowCheckbox({
export function ChainTableOverlay({
open,
nodes,
edges,
chains: storedChains,
sessions,
fileSources,
onClose,
}: {
open: boolean;
nodes: GraphNode[];
edges: GraphEdge[];
chains: GraphChain[];
sessions: Record<string, string>;
fileSources: Record<string, string>;
onClose: () => void;
@@ -618,7 +618,7 @@ export function ChainTableOverlay({
const byId = new Map(nodes.map((n) => [n.id, n]));
const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
const derived = chainsForSelection(selectedNodes, edges);
const derived = chainsForSelection(selectedNodes, storedChains);
const nodeIds = [...selection].sort();
const toggleNodes = (ids: string[], next: boolean) =>
@@ -645,7 +645,7 @@ export function ChainTableOverlay({
>
<ChainTableBody
nodes={nodes}
edges={edges}
chains={storedChains}
sessions={sessions}
fileSources={fileSources}
query={query}
+8 -24
View File
@@ -65,7 +65,6 @@ import {
sanitizeRegionTuning,
type PhysicsTuning,
type RegionTuning,
type EdgeType,
INCREMENTAL_GRAVITY,
LINK_DISTANCE,
type DepthAnchorMode,
@@ -86,14 +85,13 @@ export interface CanvasNode {
label: string;
}
/** One directed edge (follows | supports); arrow points prerequisite → dependent. */
/** One directed edge of the DERIVED view (chains-primary, 2026-09-30):
* premise → implication node → conclusion, UNTYPED — every arrow is an
* implication, so every arrow renders the double-shaft glyph (⟹) +
* arrowhead (DECIDED 2026-09-14, docs/vision-edge-glyph.md). */
export interface CanvasEdge {
from: string;
to: string;
/** Relation type: `follows` renders the double-shaft glyph (⟹),
* `supports` the plain single line (DECIDED 2026-09-14,
* docs/vision-edge-glyph.md). */
type: EdgeType;
}
/** One cluster region: member node ids + the LLM-coined label (or null). */
@@ -623,10 +621,6 @@ export class GraphCanvasRenderer {
private builtVersion = -1; // version the cache was built at
private nodesPath: Path2D | null = null;
private shaftsPath: Path2D = new Path2D();
/** Supports shafts draw THINNER (owner, 2026-09-29): their own path +
* stroke pass at edgeWidth × supportsWidth. Arrowheads share the one
* heads path at full geometry (owner pick: shaft only). */
private supportsShaftsPath: Path2D = new Path2D();
private headsPath: Path2D = new Path2D();
private regionCanvas: HTMLCanvasElement | null = null;
/** World rect the bitmap covers (drawImage target coords). */
@@ -1237,32 +1231,26 @@ export class GraphCanvasRenderer {
// relation types draw the same arrow for now — the double-shaft
// glyph was rejected (2026-09-15); differentiation is pending a new
// approach. Two owner rulings 2026-09-29: the supports draw toggle
// (off skips the paths entirely — a draw-only hide; the sim still
// feels them) and the supports SHAFT thickness split (supports get
// their own path stroked at edgeWidth × supportsWidth; heads stay
// full-geometry in the one shared path).
// CHAINS-PRIMARY (2026-09-30): one path family — every derived arrow
// is an implication, double-shafted + arrowheaded uniformly.
const arrowLen = settings.arrowLength;
const shafts = new Path2D();
const supportsShafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
if (!settings.showSupports && e.type === "supports") continue;
if (!isVisible(e.from) || !isVisible(e.to)) continue;
const a = this.nodeById.get(e.from);
const b = this.nodeById.get(e.to);
if (!a || !b) continue;
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
if (!g) continue;
const path = e.type === "supports" ? supportsShafts : shafts;
path.moveTo(g.shaftFrom.x, g.shaftFrom.y);
path.lineTo(g.shaftTo.x, g.shaftTo.y);
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
heads.moveTo(g.tip.x, g.tip.y);
heads.lineTo(g.left.x, g.left.y);
heads.lineTo(g.right.x, g.right.y);
heads.closePath();
}
this.shaftsPath = shafts;
this.supportsShaftsPath = supportsShafts;
this.headsPath = heads;
// Cluster regions are NOT built here: they rasterize into their own
@@ -1639,10 +1627,6 @@ export class GraphCanvasRenderer {
ctx.lineWidth = settings.edgeWidth / k;
ctx.lineCap = "round";
ctx.stroke(this.shaftsPath);
if (settings.showSupports) {
ctx.lineWidth = Math.max(settings.edgeWidth * settings.supportsWidth, 0.5) / k;
ctx.stroke(this.supportsShaftsPath);
}
ctx.fill(this.headsPath);
}
+6 -83
View File
@@ -51,10 +51,9 @@
*
* Rendering settings (2026-09-13): the gear in the search panel reveals
* five sliders (node size, edge width, arrowhead length, label size, the
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides
* supports edges from the canvas — draw-only; physics and detail cards
* still see them) with its shaft-thickness slider (0–1 × Edge width,
* default 0.5; heads stay full), and the cluster-labels checkbox. SERVER-persisted
* zoom threshold), and the cluster-labels checkbox. (The supports-edge
* draw toggle + thickness slider died with supports edges themselves —
* chains-primary, 2026-09-30.) SERVER-persisted
* (data/render-settings.json via /api/graph/render-settings): loaded
* once after mount, saved debounced on every user change — one source
* of truth shared by every browser. The label-zoom slider is log-scaled
@@ -532,36 +531,7 @@ function GraphSettingsControl({
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))}
/>
{/* Supports draw toggle (owner, 2026-09-29): hides the single-line
supports edges from the canvas — follows (⟹-meant) stay. Draw-only:
physics + detail cards still see them. Last rows of the FIRST
section; the border-t below starts Regions. */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Supports edges</span>
<input
type="checkbox"
checked={settings.showSupports}
onChange={(e) =>
changeSettings((cur) => ({ ...cur, showSupports: e.target.checked }))}
className="accent-primary"
/>
</label>
{/* Supports shaft thickness (owner, 2026-09-29): a LINEAR 0–1
ratio of Edge width (not a ladder — it's a ratio; default 0.5
dead-center anyway, step 0.1 = 11 stops). Grayed out by the
toggle above (the Dilation pattern); 0 floors to a hairline in
the renderer — arrowheads never scale (shaft-only ruling). */}
<SettingSlider
label="Supports thickness"
disabled={!settings.showSupports}
min={0}
max={1}
step={0.1}
value={settings.supportsWidth}
display={`${Math.round(settings.supportsWidth * 100) / 100}×`}
onChange={(v) => changeSettings((cur) => ({ ...cur, supportsWidth: v }))}
/>
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
dropdown for the bitmap resolution (it's a discrete
choice, not a free number). Server-persisted like the
sizes; every range end was sweep-validated on the real
@@ -1411,7 +1381,7 @@ export function GraphView() {
y: positions.get(n.id)?.y ?? 0,
label: n.name,
})),
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to, type: e.type })),
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
colors: {
node: tokenHex(NODE_COLOR_TOKEN),
@@ -1637,53 +1607,6 @@ export function GraphView() {
>
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{(() => {
// EDGES (owner 2026-09-27): edge classification was entirely
// hidden in the UI — the card now shows every edge this node
// participates in, grouped by direction + type, above the
// sessions list. Semantics (owner 2026-09-28): an edge A→B means A implies/
// supports B — OUTGOING edges list under "What follows from this" /
// "What this supports", INCOMING under "Follows from" / "Supported by".
// Only present groups render.
const id = selected.node.id;
const nodeById = new Map((snapshot?.nodes ?? []).map((n) => [n.id, n]));
const label = (nid: string) => nodeById.get(nid)?.name ?? nid;
const groups: { title: string; names: string[] }[] = [
{
title: "What follows from this",
names: (snapshot?.edges ?? []).filter((e) => e.from === id && e.type === "follows").map((e) => label(e.to)),
},
{
title: "What this supports",
names: (snapshot?.edges ?? []).filter((e) => e.from === id && e.type === "supports").map((e) => label(e.to)),
},
{
title: "Follows from",
names: (snapshot?.edges ?? []).filter((e) => e.to === id && e.type === "follows").map((e) => label(e.from)),
},
{
title: "Supported by",
names: (snapshot?.edges ?? []).filter((e) => e.to === id && e.type === "supports").map((e) => label(e.from)),
},
].filter((g) => g.names.length > 0);
if (groups.length === 0) return null;
return (
<div className="mt-3 flex flex-col gap-1">
{groups.map((g) => (
<div key={g.title} className="flex flex-col gap-0.5">
<div className="text-xs text-muted-foreground">{g.title}</div>
<div className="flex flex-wrap gap-1">
{g.names.map((name, i) => (
<span key={`${name}-${i}`} className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
{name}
</span>
))}
</div>
</div>
))}
</div>
);
})()}
{selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
@@ -1734,7 +1657,7 @@ export function GraphView() {
<ChainTableOverlay
open={chainsOpen && !isEmpty}
nodes={snapshot?.nodes ?? []}
edges={snapshot?.edges ?? []}
chains={snapshot?.chains ?? []}
sessions={snapshot?.sessions ?? {}}
fileSources={snapshot?.fileSources ?? {}}
onClose={() => setChainsOpen(false)}