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:
+10
-10
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user