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,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
|
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 { TopBar } from "@/components/top-bar";
|
||||||
import { PracticeDialog } from "@/components/practice-dialog";
|
import { PracticeDialog } from "@/components/practice-dialog";
|
||||||
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
|
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
|
||||||
@@ -99,7 +99,7 @@ interface ClusterGroup {
|
|||||||
* via props; the overlay wrapper below owns it. */
|
* via props; the overlay wrapper below owns it. */
|
||||||
export function ChainTableBody({
|
export function ChainTableBody({
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
chains: storedChains,
|
||||||
sessions,
|
sessions,
|
||||||
fileSources,
|
fileSources,
|
||||||
query,
|
query,
|
||||||
@@ -118,7 +118,7 @@ export function ChainTableBody({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
chains: GraphChain[];
|
||||||
sessions: Record<string, string>;
|
sessions: Record<string, string>;
|
||||||
fileSources: Record<string, string>;
|
fileSources: Record<string, string>;
|
||||||
query: string;
|
query: string;
|
||||||
@@ -140,7 +140,7 @@ export function ChainTableBody({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
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() !== "";
|
const searching = query.trim() !== "";
|
||||||
|
|
||||||
// The fuzzy filter: a chain is visible when ANY member matches on
|
// 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).
|
// The derived shape under the floating bar (the agents' ACTUAL input).
|
||||||
const derived = useMemo(() => {
|
const derived = useMemo(() => {
|
||||||
const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
|
const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
|
||||||
return chainsForSelection(selectedNodes, edges);
|
return chainsForSelection(selectedNodes, storedChains);
|
||||||
}, [selection, byId, edges]);
|
}, [selection, byId, storedChains]);
|
||||||
|
|
||||||
const nameOf = (id: string) => byId.get(id)?.name ?? id;
|
const nameOf = (id: string) => byId.get(id)?.name ?? id;
|
||||||
const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? "";
|
const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? "";
|
||||||
@@ -583,14 +583,14 @@ function RowCheckbox({
|
|||||||
export function ChainTableOverlay({
|
export function ChainTableOverlay({
|
||||||
open,
|
open,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
chains: storedChains,
|
||||||
sessions,
|
sessions,
|
||||||
fileSources,
|
fileSources,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
chains: GraphChain[];
|
||||||
sessions: Record<string, string>;
|
sessions: Record<string, string>;
|
||||||
fileSources: Record<string, string>;
|
fileSources: Record<string, string>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -618,7 +618,7 @@ export function ChainTableOverlay({
|
|||||||
|
|
||||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
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 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 nodeIds = [...selection].sort();
|
||||||
|
|
||||||
const toggleNodes = (ids: string[], next: boolean) =>
|
const toggleNodes = (ids: string[], next: boolean) =>
|
||||||
@@ -645,7 +645,7 @@ export function ChainTableOverlay({
|
|||||||
>
|
>
|
||||||
<ChainTableBody
|
<ChainTableBody
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
chains={storedChains}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
fileSources={fileSources}
|
fileSources={fileSources}
|
||||||
query={query}
|
query={query}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import {
|
|||||||
sanitizeRegionTuning,
|
sanitizeRegionTuning,
|
||||||
type PhysicsTuning,
|
type PhysicsTuning,
|
||||||
type RegionTuning,
|
type RegionTuning,
|
||||||
type EdgeType,
|
|
||||||
INCREMENTAL_GRAVITY,
|
INCREMENTAL_GRAVITY,
|
||||||
LINK_DISTANCE,
|
LINK_DISTANCE,
|
||||||
type DepthAnchorMode,
|
type DepthAnchorMode,
|
||||||
@@ -86,14 +85,13 @@ export interface CanvasNode {
|
|||||||
label: string;
|
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 {
|
export interface CanvasEdge {
|
||||||
from: string;
|
from: string;
|
||||||
to: 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). */
|
/** 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 builtVersion = -1; // version the cache was built at
|
||||||
private nodesPath: Path2D | null = null;
|
private nodesPath: Path2D | null = null;
|
||||||
private shaftsPath: Path2D = new Path2D();
|
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 headsPath: Path2D = new Path2D();
|
||||||
private regionCanvas: HTMLCanvasElement | null = null;
|
private regionCanvas: HTMLCanvasElement | null = null;
|
||||||
/** World rect the bitmap covers (drawImage target coords). */
|
/** 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
|
// relation types draw the same arrow for now — the double-shaft
|
||||||
// glyph was rejected (2026-09-15); differentiation is pending a new
|
// glyph was rejected (2026-09-15); differentiation is pending a new
|
||||||
// approach. Two owner rulings 2026-09-29: the supports draw toggle
|
// approach. Two owner rulings 2026-09-29: the supports draw toggle
|
||||||
// (off skips the paths entirely — a draw-only hide; the sim still
|
// CHAINS-PRIMARY (2026-09-30): one path family — every derived arrow
|
||||||
// feels them) and the supports SHAFT thickness split (supports get
|
// is an implication, double-shafted + arrowheaded uniformly.
|
||||||
// their own path stroked at edgeWidth × supportsWidth; heads stay
|
|
||||||
// full-geometry in the one shared path).
|
|
||||||
const arrowLen = settings.arrowLength;
|
const arrowLen = settings.arrowLength;
|
||||||
const shafts = new Path2D();
|
const shafts = new Path2D();
|
||||||
const supportsShafts = new Path2D();
|
|
||||||
const heads = new Path2D();
|
const heads = new Path2D();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
if (!settings.showSupports && e.type === "supports") continue;
|
|
||||||
if (!isVisible(e.from) || !isVisible(e.to)) continue;
|
if (!isVisible(e.from) || !isVisible(e.to)) continue;
|
||||||
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 g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
const path = e.type === "supports" ? supportsShafts : shafts;
|
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
||||||
path.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
||||||
path.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.supportsShaftsPath = supportsShafts;
|
|
||||||
this.headsPath = heads;
|
this.headsPath = heads;
|
||||||
|
|
||||||
// Cluster regions are NOT built here: they rasterize into their own
|
// Cluster regions are NOT built here: they rasterize into their own
|
||||||
@@ -1639,10 +1627,6 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.lineWidth = settings.edgeWidth / k;
|
ctx.lineWidth = settings.edgeWidth / k;
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.stroke(this.shaftsPath);
|
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);
|
ctx.fill(this.headsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,9 @@
|
|||||||
*
|
*
|
||||||
* Rendering settings (2026-09-13): the gear in the search panel reveals
|
* Rendering settings (2026-09-13): the gear in the search panel reveals
|
||||||
* five sliders (node size, edge width, arrowhead length, label size, the
|
* five sliders (node size, edge width, arrowhead length, label size, the
|
||||||
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides
|
* zoom threshold), and the cluster-labels checkbox. (The supports-edge
|
||||||
* supports edges from the canvas — draw-only; physics and detail cards
|
* draw toggle + thickness slider died with supports edges themselves —
|
||||||
* still see them) with its shaft-thickness slider (0–1 × Edge width,
|
* chains-primary, 2026-09-30.) SERVER-persisted
|
||||||
* default 0.5; heads stay full), and the cluster-labels checkbox. SERVER-persisted
|
|
||||||
* (data/render-settings.json via /api/graph/render-settings): loaded
|
* (data/render-settings.json via /api/graph/render-settings): loaded
|
||||||
* once after mount, saved debounced on every user change — one source
|
* once after mount, saved debounced on every user change — one source
|
||||||
* of truth shared by every browser. The label-zoom slider is log-scaled
|
* of truth shared by every browser. The label-zoom slider is log-scaled
|
||||||
@@ -532,36 +531,7 @@ function GraphSettingsControl({
|
|||||||
onChange={(p) =>
|
onChange={(p) =>
|
||||||
changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))}
|
changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))}
|
||||||
/>
|
/>
|
||||||
{/* Supports draw toggle (owner, 2026-09-29): hides the single-line
|
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||||
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
|
|
||||||
dropdown for the bitmap resolution (it's a discrete
|
dropdown for the bitmap resolution (it's a discrete
|
||||||
choice, not a free number). Server-persisted like the
|
choice, not a free number). Server-persisted like the
|
||||||
sizes; every range end was sweep-validated on the real
|
sizes; every range end was sweep-validated on the real
|
||||||
@@ -1411,7 +1381,7 @@ export function GraphView() {
|
|||||||
y: positions.get(n.id)?.y ?? 0,
|
y: positions.get(n.id)?.y ?? 0,
|
||||||
label: n.name,
|
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 })),
|
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
|
||||||
colors: {
|
colors: {
|
||||||
node: tokenHex(NODE_COLOR_TOKEN),
|
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="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>
|
<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 ? (
|
{selected.sourceIds.length > 0 ? (
|
||||||
<div className="mt-3 flex flex-col gap-1">
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
<div className="text-xs text-muted-foreground">Sessions</div>
|
<div className="text-xs text-muted-foreground">Sessions</div>
|
||||||
@@ -1734,7 +1657,7 @@ export function GraphView() {
|
|||||||
<ChainTableOverlay
|
<ChainTableOverlay
|
||||||
open={chainsOpen && !isEmpty}
|
open={chainsOpen && !isEmpty}
|
||||||
nodes={snapshot?.nodes ?? []}
|
nodes={snapshot?.nodes ?? []}
|
||||||
edges={snapshot?.edges ?? []}
|
chains={snapshot?.chains ?? []}
|
||||||
sessions={snapshot?.sessions ?? {}}
|
sessions={snapshot?.sessions ?? {}}
|
||||||
fileSources={snapshot?.fileSources ?? {}}
|
fileSources={snapshot?.fileSources ?? {}}
|
||||||
onClose={() => setChainsOpen(false)}
|
onClose={() => setChainsOpen(false)}
|
||||||
|
|||||||
@@ -60,11 +60,12 @@ export type ModelRole =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The LIVE graph store (src/store/graph.ts) — owned by the app's extraction
|
* The LIVE graph store (src/store/graph.ts) — owned by the app's extraction
|
||||||
* pipeline. Line shapes: src/core/contracts.ts.
|
* pipeline. Line shapes: src/core/contracts.ts. CHAINS ARE THE STORED
|
||||||
|
* TRUTH (owner, 2026-09-30): edges are a derived view, never stored.
|
||||||
*/
|
*/
|
||||||
export const LEDGER_FILES = {
|
export const LEDGER_FILES = {
|
||||||
nodes: path.join(PATHS.graph, "nodes.jsonl"),
|
nodes: path.join(PATHS.graph, "nodes.jsonl"),
|
||||||
edges: path.join(PATHS.graph, "edges.jsonl"),
|
chains: path.join(PATHS.graph, "chains.jsonl"),
|
||||||
sessions: path.join(PATHS.graph, "sessions.jsonl"),
|
sessions: path.join(PATHS.graph, "sessions.jsonl"),
|
||||||
/** Per-message extraction coverage (contracts/graph.ts CoverageEntry;
|
/** Per-message extraction coverage (contracts/graph.ts CoverageEntry;
|
||||||
* the checkpoint's replacement for chat extractions — notes keep
|
* the checkpoint's replacement for chat extractions — notes keep
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async function main() {
|
|||||||
const stats = await pipeline.runExtraction(chat.id);
|
const stats = await pipeline.runExtraction(chat.id);
|
||||||
console.log(
|
console.log(
|
||||||
`[backfill] ${chat.id} (${chat.title.slice(0, 40)}): ` +
|
`[backfill] ${chat.id} (${chat.title.slice(0, 40)}): ` +
|
||||||
`${stats.nodes} nodes, ${stats.edges} edges in ${((Date.now() - t0) / 1000).toFixed(1)}s`,
|
`${stats.nodes} nodes, ${stats.chains ?? 0} chains in ${((Date.now() - t0) / 1000).toFixed(1)}s`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[backfill] ${chat.id} FAILED:`, err);
|
console.error(`[backfill] ${chat.id} FAILED:`, err);
|
||||||
@@ -35,7 +35,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const graph = getGraph(graphStore);
|
const graph = getGraph(graphStore);
|
||||||
console.log(
|
console.log(
|
||||||
`[backfill] done: ${graph.nodes.length} nodes, ${graph.edges.length} edges`,
|
`[backfill] done: ${graph.nodes.length} nodes, ${graph.chains.length} chains`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function main() {
|
|||||||
getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens,
|
getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens,
|
||||||
});
|
});
|
||||||
const queue = [...files];
|
const queue = [...files];
|
||||||
const totals = { nodes: 0, edges: 0, failed: 0 };
|
const totals = { nodes: 0, chains: 0, failed: 0 };
|
||||||
const workers = Array.from({ length: Math.min(limit, files.length) }, async () => {
|
const workers = Array.from({ length: Math.min(limit, files.length) }, async () => {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const file = queue.shift();
|
const file = queue.shift();
|
||||||
@@ -43,9 +43,9 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const stats = await pipeline.importNotes({ filePath, text: fs.readFileSync(filePath, "utf-8") });
|
const stats = await pipeline.importNotes({ filePath, text: fs.readFileSync(filePath, "utf-8") });
|
||||||
totals.nodes += stats.nodes;
|
totals.nodes += stats.nodes;
|
||||||
totals.edges += stats.edges;
|
totals.chains += stats.chains ?? 0;
|
||||||
console.log(
|
console.log(
|
||||||
`[import-notes] ${file}: ${stats.nodes} nodes, ${stats.edges} edges in ${((Date.now() - t0) / 1000).toFixed(1)}s`,
|
`[import-notes] ${file}: ${stats.nodes} nodes, ${stats.chains ?? 0} chains in ${((Date.now() - t0) / 1000).toFixed(1)}s`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
totals.failed++;
|
totals.failed++;
|
||||||
@@ -57,8 +57,8 @@ async function main() {
|
|||||||
|
|
||||||
const graph = getGraph(stores.graphStore);
|
const graph = getGraph(stores.graphStore);
|
||||||
console.log(
|
console.log(
|
||||||
`[import-notes] done: +${totals.nodes} nodes, +${totals.edges} edges, ${totals.failed} failed — ` +
|
`[import-notes] done: +${totals.nodes} nodes, +${totals.chains} chains, ${totals.failed} failed — ` +
|
||||||
`graph now ${graph.nodes.length} nodes, ${graph.edges.length} edges`,
|
`graph now ${graph.nodes.length} nodes, ${graph.chains.length} chains`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-11
@@ -19,20 +19,28 @@ const depthMode: "off" | "vertical" | "radial" =
|
|||||||
depthArg === "--depth=vertical" ? "vertical" : depthArg === "--depth=radial" ? "radial" : "off";
|
depthArg === "--depth=vertical" ? "vertical" : depthArg === "--depth=radial" ? "radial" : "off";
|
||||||
import { incrementalLayout } from "../src/core/layout.ts";
|
import { incrementalLayout } from "../src/core/layout.ts";
|
||||||
import { birthSessionPartition } from "../src/core/session-clusters.ts";
|
import { birthSessionPartition } from "../src/core/session-clusters.ts";
|
||||||
|
import { deriveView, getGraph } from "../src/core/graph.ts";
|
||||||
import type { GraphNode, Position } from "../src/core/contracts.ts";
|
import type { GraphNode, Position } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
|
||||||
// Unique nodes (lines may repeat an id — first defines).
|
// CHAINS-PRIMARY (2026-09-30): the stored truth is nodes + chains; the
|
||||||
const seen = new Set<string>();
|
// replay lays out the DERIVED view (implication nodes included, untyped
|
||||||
const nodes: GraphNode[] = [];
|
// arrows) exactly like the real settle.
|
||||||
for (const line of graphStore.readNodes()) {
|
const graph = getGraph(graphStore);
|
||||||
if (seen.has(line.id)) continue;
|
const nodes = graph.nodes; // getGraph already merges lines by id
|
||||||
seen.add(line.id);
|
const view = deriveView(graph);
|
||||||
nodes.push(line);
|
const edges = view.edges;
|
||||||
}
|
const viewNodeById = new Map(view.nodes.map((n) => [n.id, n]));
|
||||||
const edges = graphStore.readEdges();
|
const chains = graph.chains;
|
||||||
|
// An implication node is delivered the moment its chain is complete
|
||||||
|
// (conclusion + every premise delivered).
|
||||||
|
const chainReady = (id: string): boolean => {
|
||||||
|
const chain = chains.find((c) => c.id === id);
|
||||||
|
if (!chain) return delivered.has(id);
|
||||||
|
return delivered.has(chain.conclusion) && chain.premises.every((p) => delivered.has(p));
|
||||||
|
};
|
||||||
// The cluster source (wave 7): the birth-session partition, derived on
|
// The cluster source (wave 7): the birth-session partition, derived on
|
||||||
// read — same as the real settle (core/layout-settle.ts).
|
// read — same as the real settle (core/layout-settle.ts).
|
||||||
const birthClusters = birthSessionPartition(nodes);
|
const birthClusters = birthSessionPartition(nodes);
|
||||||
@@ -58,7 +66,7 @@ function main() {
|
|||||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
const unmappedFallback = sources[sources.length - 1]?.id ?? "";
|
const unmappedFallback = sources[sources.length - 1]?.id ?? "";
|
||||||
|
|
||||||
console.log(`[replay] ${nodes.length} unique nodes, ${edges.length} edge lines, ${sources.length} sources`);
|
console.log(`[replay] ${nodes.length} nodes, ${chains.length} chains, ${sources.length} sources`);
|
||||||
let maxOldShift = 0;
|
let maxOldShift = 0;
|
||||||
let shiftReport = "";
|
let shiftReport = "";
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
@@ -74,13 +82,18 @@ function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const n of batch) delivered.add(n.id);
|
for (const n of batch) delivered.add(n.id);
|
||||||
|
// Ready chains' implication nodes ride the same batch (they have no
|
||||||
|
// provenance of their own — the chain's completeness delivers them).
|
||||||
|
for (const chain of chains) {
|
||||||
|
if (chainReady(chain.id)) delivered.add(chain.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Edges activate once BOTH endpoints exist (a later file can claim
|
// Edges activate once BOTH endpoints exist (a later file can claim
|
||||||
// one endpoint — the edge lights up then).
|
// one endpoint — the edge lights up then).
|
||||||
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
|
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
|
||||||
|
|
||||||
const before = new Map(positions);
|
const before = new Map(positions);
|
||||||
const batchNodes = nodes.filter((n) => delivered.has(n.id));
|
const batchNodes = view.nodes.filter((n) => delivered.has(n.id));
|
||||||
positions = incrementalLayout(batchNodes, activeEdges, positions, {
|
positions = incrementalLayout(batchNodes, activeEdges, positions, {
|
||||||
// The same partition the real settle would see for this batch:
|
// The same partition the real settle would see for this batch:
|
||||||
// the birth-session clusters, filtered to delivered nodes.
|
// the birth-session clusters, filtered to delivered nodes.
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type { GraphStore } from "../../core/ports.ts";
|
|||||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||||
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
||||||
import { teacherProseBlocks } from "../../core/coverage.ts";
|
import { teacherProseBlocks } from "../../core/coverage.ts";
|
||||||
import { chainsToEdges } from "../../core/practice.ts";
|
|
||||||
import type { LhMessage } from "../../core/native-messages.ts";
|
import type { LhMessage } from "../../core/native-messages.ts";
|
||||||
import { auditorGraphTools } from "../tools/graph.ts";
|
import { auditorGraphTools } from "../tools/graph.ts";
|
||||||
import { runSubmitAgent } from "./submit-agent.ts";
|
import { runSubmitAgent } from "./submit-agent.ts";
|
||||||
@@ -266,17 +265,15 @@ function validateFindingsChains(value: FindingsSubmit): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The translation seam (extractor redesign, 2026-09-30): the wire
|
/** (chains-primary, owner 2026-09-30) the wire IS the pipeline shape
|
||||||
* speaks CHAINS, the pipeline speaks follows-EDGES — SessionFindings
|
* now: propositions + chains, no translation — the stores keep the
|
||||||
* and everything downstream (auditor, resolution, merge, coverage,
|
* chains and the derived edge view is recomputed on read. The fill
|
||||||
* store) is UNCHANGED. One math, one place: chainsToEdges sits next
|
* already ran inside runSubmitAgent; this only re-asserts the
|
||||||
* to chainsToGraph in core/practice.ts. */
|
* aliases-filled type. */
|
||||||
function expandFindings(raw: FindingsSubmit): SessionFindings {
|
const asSessionFindings = (raw: FindingsSubmit): SessionFindings => ({
|
||||||
return {
|
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
|
||||||
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
|
chains: raw.chains,
|
||||||
edges: chainsToEdges(raw.chains),
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts {
|
function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts {
|
||||||
return {
|
return {
|
||||||
@@ -346,7 +343,7 @@ function runExtractor(
|
|||||||
onStep,
|
onStep,
|
||||||
neverSubmitted: "the extractor never submitted findings",
|
neverSubmitted: "the extractor never submitted findings",
|
||||||
prepareArguments: healStringifiedArrays,
|
prepareArguments: healStringifiedArrays,
|
||||||
}).then(expandFindings);
|
}).then(asSessionFindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The extractor over ONE source of raw personal notes (the format-agnostic
|
/** The extractor over ONE source of raw personal notes (the format-agnostic
|
||||||
@@ -371,7 +368,7 @@ function runNotesExtractor(
|
|||||||
onStep,
|
onStep,
|
||||||
neverSubmitted: "the extractor never submitted findings",
|
neverSubmitted: "the extractor never submitted findings",
|
||||||
prepareArguments: healStringifiedArrays,
|
prepareArguments: healStringifiedArrays,
|
||||||
}).then(expandFindings);
|
}).then(asSessionFindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── the auditor ───────────────────────────────────────────────────────────
|
// ─── the auditor ───────────────────────────────────────────────────────────
|
||||||
@@ -379,7 +376,7 @@ function runNotesExtractor(
|
|||||||
|
|
||||||
const AUDITOR_INSTRUCTIONS = `You are the linking stage of a learning application's extraction pipeline. An extractor
|
const AUDITOR_INSTRUCTIONS = `You are the linking stage of a learning application's extraction pipeline. An extractor
|
||||||
has read a source (a teaching session, or the author's personal notes) and reported findings: propositions and
|
has read a source (a teaching session, or the author's personal notes) and reported findings: propositions and
|
||||||
the follows edges of their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check
|
their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check
|
||||||
its work. Your job is IDENTITY: connecting those findings to the CURRENT knowledge graph, preparing the merge.
|
its work. Your job is IDENTITY: connecting those findings to the CURRENT knowledge graph, preparing the merge.
|
||||||
|
|
||||||
You explore the graph through your tools: list_sessions, list_propositions, and find (glob over names +
|
You explore the graph through your tools: list_sessions, list_propositions, and find (glob over names +
|
||||||
@@ -406,8 +403,8 @@ IDENTITY — links, per proposition:
|
|||||||
propositions — different claims about the world — stay apart. You are NOT guarding against wrong merges; you are
|
propositions — different claims about the world — stay apart. You are NOT guarding against wrong merges; you are
|
||||||
hunting for right ones.
|
hunting for right ones.
|
||||||
|
|
||||||
Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions or the extractor's edges —
|
Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions —
|
||||||
the merger already has them. You NEVER create edges — relations between the new propositions come from the
|
the merger already has them. You NEVER create relations between propositions — they come from the
|
||||||
extractor alone, and the batch connects to the existing graph ONLY through the equivalences you identify. Your
|
extractor alone, and the batch connects to the existing graph ONLY through the equivalences you identify. Your
|
||||||
entire deliverable is ONE submit_review call.
|
entire deliverable is ONE submit_review call.
|
||||||
|
|
||||||
@@ -748,7 +745,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
|
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
|
||||||
const graph = getGraph(graphStore);
|
const graph = getGraph(graphStore);
|
||||||
console.info(
|
console.info(
|
||||||
`[extraction ${jobKey}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` +
|
`[extraction ${jobKey}] auditor starting (${findings.propositions.length} propositions, ` +
|
||||||
`${graph.nodes.length} graph nodes)`,
|
`${graph.nodes.length} graph nodes)`,
|
||||||
);
|
);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
@@ -758,8 +755,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// exploration tools instead of an injected dump.
|
// exploration tools instead of an injected dump.
|
||||||
const auditResult = await runAuditor(
|
const auditResult = await runAuditor(
|
||||||
stageEngine("auditor", deps.auditor),
|
stageEngine("auditor", deps.auditor),
|
||||||
`Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted):\n\n` +
|
`Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted; the propositions only — the reasoning chains are the merger's business, owner 2026-09-30):\n\n` +
|
||||||
`${JSON.stringify(findings, null, 2)}\n\n` +
|
`${JSON.stringify({ propositions: findings.propositions }, null, 2)}\n\n` +
|
||||||
`The current knowledge graph has ${graph.nodes.length} node(s). Explore it with your tools and link what links.`,
|
`The current knowledge graph has ${graph.nodes.length} node(s). Explore it with your tools and link what links.`,
|
||||||
signal,
|
signal,
|
||||||
(usage, model, step, finishReason, tools) => {
|
(usage, model, step, finishReason, tools) => {
|
||||||
@@ -848,13 +845,13 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
* per job and never throw — the source's run returns what actually
|
* per job and never throw — the source's run returns what actually
|
||||||
* merged. */
|
* merged. */
|
||||||
function aggregateJobs(sourceId: string, settled: PromiseSettledResult<EmissionStats>[]): EmissionStats {
|
function aggregateJobs(sourceId: string, settled: PromiseSettledResult<EmissionStats>[]): EmissionStats {
|
||||||
const stats: EmissionStats = { nodes: 0, edges: 0 };
|
const stats: EmissionStats = { nodes: 0, chains: 0 };
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const result of settled) {
|
for (const result of settled) {
|
||||||
if (result.status === "fulfilled") {
|
if (result.status === "fulfilled") {
|
||||||
stats.nodes += result.value.nodes;
|
stats.nodes += result.value.nodes;
|
||||||
stats.edges += result.value.edges;
|
stats.chains! += result.value.chains ?? 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const err = result.reason;
|
const err = result.reason;
|
||||||
@@ -874,7 +871,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
||||||
if (inFlight.has(chatId)) return { nodes: 0, edges: 0 };
|
if (inFlight.has(chatId)) return { nodes: 0, chains: 0 };
|
||||||
inFlight.add(chatId);
|
inFlight.add(chatId);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
// COVERAGE (vision-extraction-coverage.md, 2026-09-15): extract ONLY
|
// COVERAGE (vision-extraction-coverage.md, 2026-09-15): extract ONLY
|
||||||
@@ -895,7 +892,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
||||||
if (covered.uncovered.length === 0) {
|
if (covered.uncovered.length === 0) {
|
||||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||||
return { nodes: 0, edges: 0 };
|
return { nodes: 0, chains: 0 };
|
||||||
}
|
}
|
||||||
const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens;
|
const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens;
|
||||||
// The batcher's one rule (owner, Wave D): a tool-call message and
|
// The batcher's one rule (owner, Wave D): a tool-call message and
|
||||||
@@ -960,7 +957,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// next snapshot fetch must already reflect the new nodes).
|
// next snapshot fetch must already reflect the new nodes).
|
||||||
settleGraphLayout(graphStore);
|
settleGraphLayout(graphStore);
|
||||||
console.info(
|
console.info(
|
||||||
`[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.edges} edges`,
|
`[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.chains ?? 0} chains`,
|
||||||
);
|
);
|
||||||
return batchStats;
|
return batchStats;
|
||||||
});
|
});
|
||||||
@@ -969,7 +966,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
);
|
);
|
||||||
const stats = aggregateJobs(chatId, settled);
|
const stats = aggregateJobs(chatId, settled);
|
||||||
console.info(
|
console.info(
|
||||||
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges ` +
|
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
|
||||||
`(${Math.round(performance.now() - t0)}ms total, ${batches.length} batch job(s))`,
|
`(${Math.round(performance.now() - t0)}ms total, ${batches.length} batch job(s))`,
|
||||||
);
|
);
|
||||||
return stats;
|
return stats;
|
||||||
@@ -1047,7 +1044,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
|
|
||||||
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
|
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
|
||||||
const { id } = input;
|
const { id } = input;
|
||||||
if (inFlight.has(id)) return { nodes: 0, edges: 0 };
|
if (inFlight.has(id)) return { nodes: 0, chains: 0 };
|
||||||
inFlight.add(id);
|
inFlight.add(id);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
@@ -1098,7 +1095,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// The layout ledger evolves with each merge — the next
|
// The layout ledger evolves with each merge — the next
|
||||||
// snapshot fetch reflects every finished chunk.
|
// snapshot fetch reflects every finished chunk.
|
||||||
settleGraphLayout(graphStore);
|
settleGraphLayout(graphStore);
|
||||||
console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.edges} edges`);
|
console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.chains ?? 0} chains`);
|
||||||
return merged;
|
return merged;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1106,7 +1103,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
);
|
);
|
||||||
const stats = aggregateJobs(id, settled);
|
const stats = aggregateJobs(id, settled);
|
||||||
console.info(
|
console.info(
|
||||||
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.edges} edges ` +
|
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
|
||||||
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
|
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
|
||||||
);
|
);
|
||||||
return stats;
|
return stats;
|
||||||
@@ -1133,7 +1130,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
|
const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
|
||||||
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
|
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
|
||||||
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
|
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
|
||||||
return { nodes: 0, edges: 0 };
|
return { nodes: 0, chains: 0 };
|
||||||
}
|
}
|
||||||
const importedAt = new Date().toISOString();
|
const importedAt = new Date().toISOString();
|
||||||
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
|
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
import type { GraphStore, PracticeStore } from "../../core/ports.ts";
|
import type { GraphStore, PracticeStore } from "../../core/ports.ts";
|
||||||
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
|
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
|
||||||
import { chainsForSelection } from "../../core/practice.ts";
|
import { chainsForSelection } from "../../core/practice.ts";
|
||||||
|
import { getGraph } from "../../core/graph.ts";
|
||||||
import {
|
import {
|
||||||
createPracticeAgents,
|
createPracticeAgents,
|
||||||
type PracticeModels,
|
type PracticeModels,
|
||||||
@@ -444,10 +445,10 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
|
|||||||
const wanted = new Set(nodeIds);
|
const wanted = new Set(nodeIds);
|
||||||
const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
|
const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
|
||||||
const selectedIds = new Set(selected.map((n) => n.id));
|
const selectedIds = new Set(selected.map((n) => n.id));
|
||||||
const edges = deps
|
// The STORED chains (chains-primary, owner 2026-09-30): chainsForSelection
|
||||||
.graphStore.readEdges()
|
// keeps a chain iff every member is selected — the same inclusion law as
|
||||||
.filter((e) => e.type === "follows" && selectedIds.has(e.from) && selectedIds.has(e.to));
|
// the pre-chains edge grouping; never the derived fan-out.
|
||||||
const chains = chainsForSelection(selected, edges);
|
const chains = chainsForSelection(selected, getGraph(deps.graphStore).chains);
|
||||||
if (chains.length === 0) throw new EmptySelectionError();
|
if (chains.length === 0) throw new EmptySelectionError();
|
||||||
const propositions: PropositionSnapshot = {};
|
const propositions: PropositionSnapshot = {};
|
||||||
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
|
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
|
||||||
|
|||||||
+61
-28
@@ -18,20 +18,51 @@
|
|||||||
*/
|
*/
|
||||||
// ─── Knowledge graph (vision §1 — global, cross-session) ────────────────────
|
// ─── Knowledge graph (vision §1 — global, cross-session) ────────────────────
|
||||||
|
|
||||||
/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a;
|
|
||||||
* supports = a grounds b without entailing it. No scalars, no encoded
|
|
||||||
* strength — strength is derived at read time from topology. */
|
|
||||||
import type { CallUsage, ExtractionRunUsage } from "./chat.ts";
|
import type { CallUsage, ExtractionRunUsage } from "./chat.ts";
|
||||||
|
|
||||||
export type EdgeType = "follows" | "supports";
|
/** One relation of the DERIVED view — chains are the stored truth (owner,
|
||||||
|
* 2026-09-30: "the graph is a lossy image of what is truly stored"), and
|
||||||
|
* the snapshot derives UNTYPED arrows from them: every premise → the
|
||||||
|
* chain's implication node, the implication node → the conclusion.
|
||||||
|
* Nothing stores or mutates these — they are rebuilt after every change
|
||||||
|
* and never converted back. */
|
||||||
|
export interface GraphEdge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stored REASONING CHAIN — an argument: premises that JOINTLY entail
|
||||||
|
* the conclusion (chains-primary, owner 2026-09-30). Independent arguments
|
||||||
|
* for the same conclusion are separate chains (they may share `conclusion`);
|
||||||
|
* co-premises within one chain are used TOGETHER. The snapshot derives an
|
||||||
|
* implication NODE per chain (id = the chain id — the arg_ namespace is its
|
||||||
|
* own, collision-free with n_) plus untyped edges premises → imp →
|
||||||
|
* conclusion. A chain referencing a dead node dies with it (full death
|
||||||
|
* cascades through every referencing chain). */
|
||||||
|
export interface GraphChain {
|
||||||
|
/** "arg_" + nanoid-like (store-assigned, the node-minting pattern). */
|
||||||
|
id: string;
|
||||||
|
/** Node ids of the argument's premises — at least one, jointly
|
||||||
|
* sufficient. Extractor-local at the wire; node ids after resolution. */
|
||||||
|
premises: string[];
|
||||||
|
/** The node id the premises jointly imply. */
|
||||||
|
conclusion: string;
|
||||||
|
/** Every (chat, message) point where this argument was asserted —
|
||||||
|
* unioned across assertion lines at read time. */
|
||||||
|
provenance: ProvenancePoint[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** What one extraction run merged into the graph ledgers — the merger's
|
/** What one extraction run merged into the graph ledgers — the merger's
|
||||||
* output, also persisted in extraction-run usage records (contracts/chat). */
|
* output, also persisted in extraction-run usage records (contracts/chat).
|
||||||
|
* `chains` is OPTIONAL IN OLD RECORDS ONLY: usage history from the
|
||||||
|
* pre-chains era carries `edges` instead — tolerated at read, never
|
||||||
|
* written again (no-backward-compat regime, owner 2026-09-30). */
|
||||||
export interface EmissionStats {
|
export interface EmissionStats {
|
||||||
/** New graph nodes appended. */
|
/** New graph nodes appended. */
|
||||||
nodes: number;
|
nodes: number;
|
||||||
/** New edge assertions appended. */
|
/** New reasoning chains appended. */
|
||||||
edges: number;
|
chains?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
|
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
|
||||||
@@ -107,14 +138,6 @@ export interface GraphNode {
|
|||||||
provenance: ProvenancePoint[];
|
provenance: ProvenancePoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
type: EdgeType;
|
|
||||||
/** Every (chat, message) point where this relation was asserted. */
|
|
||||||
provenance: ProvenancePoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** sessions.jsonl line — the emission checkpoint for one chat (which chats
|
/** sessions.jsonl line — the emission checkpoint for one chat (which chats
|
||||||
* have been folded, and through which updatedAt — never fold twice). */
|
* have been folded, and through which updatedAt — never fold twice). */
|
||||||
export interface SessionCheckpoint {
|
export interface SessionCheckpoint {
|
||||||
@@ -143,7 +166,9 @@ export interface CoverageEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Store format (src/store/graph.ts): JSONL ledgers at config.LEDGER_FILES
|
* Store format (src/store/graph.ts): JSONL ledgers at config.LEDGER_FILES
|
||||||
* (nodes/edges/mappings/sessions), all append-only.
|
* (nodes/chains/sessions), all append-only. Edges are NEVER stored — the
|
||||||
|
* derived view (implication nodes + untyped arrows) is rebuilt from chains
|
||||||
|
* on every read (chains-primary, owner 2026-09-30).
|
||||||
*
|
*
|
||||||
* nodes.jsonl one line per node — the FIRST line for an id defines
|
* nodes.jsonl one line per node — the FIRST line for an id defines
|
||||||
* name/statement/createdAt; later lines for the
|
* name/statement/createdAt; later lines for the
|
||||||
@@ -151,8 +176,9 @@ export interface CoverageEntry {
|
|||||||
* their statements/aliases join `aliases`, their
|
* their statements/aliases join `aliases`, their
|
||||||
* provenance unions; the name stays the first line's.
|
* provenance unions; the name stays the first line's.
|
||||||
* getGraph merges by id.
|
* getGraph merges by id.
|
||||||
* edges.jsonl one line per edge ASSERTION; readers merge by
|
* chains.jsonl one line per chain ASSERTION; readers merge by
|
||||||
* (from,to,type), unioning provenance.
|
* (premises, conclusion), unioning provenance — the
|
||||||
|
* first line's arg_ id is the chain's identity.
|
||||||
*
|
*
|
||||||
* The derived graph is rebuilt by reading the ledgers — store events,
|
* The derived graph is rebuilt by reading the ledgers — store events,
|
||||||
* derive state. sessions.jsonl is the extraction checkpoint: which chats
|
* derive state. sessions.jsonl is the extraction checkpoint: which chats
|
||||||
@@ -202,21 +228,20 @@ export interface ExtractedProposition {
|
|||||||
* + structure, nothing else. */
|
* + structure, nothing else. */
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One proposed relation. Endpoints are extractor-local proposition ids
|
/** One proposed argument (extractor redesign, 2026-09-30). Endpoints are
|
||||||
* ("p1", …) — the extractor is the ONLY stage that ever proposes edges
|
* extractor-local proposition ids ("p1", …) — the extractor is the ONLY
|
||||||
* (the auditor's edges argument is gone, owner 2026-09-28 evening). */
|
* stage that ever proposes chains; the merger resolves them to node ids. */
|
||||||
export interface ExtractedEdge {
|
export interface ExtractedChain {
|
||||||
/** Proposition id that is followed / supported. */
|
/** Proposition ids that JOINTLY entail the conclusion. */
|
||||||
from: string;
|
premises: string[];
|
||||||
/** Proposition id that follows / is supported. */
|
/** The proposition id the premises jointly imply. */
|
||||||
to: string;
|
conclusion: string;
|
||||||
type: EdgeType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The extractor's output shape: the raw reasoning chains. */
|
/** The extractor's output shape: the raw reasoning chains. */
|
||||||
export interface SessionFindings {
|
export interface SessionFindings {
|
||||||
propositions: ExtractedProposition[];
|
propositions: ExtractedProposition[];
|
||||||
edges: ExtractedEdge[];
|
chains: ExtractedChain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One identity verdict: the extractor's local proposition id → the
|
/** One identity verdict: the extractor's local proposition id → the
|
||||||
@@ -279,8 +304,16 @@ export interface GraphClusterGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphSnapshot {
|
export interface GraphSnapshot {
|
||||||
|
/** Proposition nodes + the DERIVED implication nodes (one per chain,
|
||||||
|
* id = chain id) — what the canvas renders. */
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
|
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
|
||||||
|
* of the stored chains — rebuilt after every change, never stored. */
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
|
/** 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. */
|
||||||
|
chains: GraphChain[];
|
||||||
/** chatId → title, for every chat that touched the graph. */
|
/** chatId → title, for every chat that touched the graph. */
|
||||||
sessions: Record<string, string>;
|
sessions: Record<string, string>;
|
||||||
/** srcId → file path, for every IMPORTED NOTES file that touched the
|
/** srcId → file path, for every IMPORTED NOTES file that touched the
|
||||||
|
|||||||
+95
-59
@@ -9,11 +9,17 @@
|
|||||||
* orchestration) owns the cross-store choreography (chat record, jobs,
|
* orchestration) owns the cross-store choreography (chat record, jobs,
|
||||||
* extraction wind-down); this module owns the LEDGERS.
|
* extraction wind-down); this module owns the LEDGERS.
|
||||||
*
|
*
|
||||||
|
* CHAINS-PRIMARY (owner, 2026-09-30): the stored truth is propositions +
|
||||||
|
* reasoning chains; edges are a derived view. Deletion therefore operates
|
||||||
|
* on CHAIN LINES where it used to operate on edge lines — a chain dies
|
||||||
|
* when its provenance empties or any member node died — and the derived
|
||||||
|
* view follows on the next read, never rewritten here.
|
||||||
|
*
|
||||||
* Two modes (owner-locked semantics):
|
* Two modes (owner-locked semantics):
|
||||||
*
|
*
|
||||||
* preserve-graph the chat's extraction bookkeeping dies — its coverage
|
* preserve-graph the chat's extraction bookkeeping dies — its coverage
|
||||||
* rows and its legacy checkpoint lines — and NOTHING else:
|
* rows and its legacy checkpoint lines — and NOTHING else:
|
||||||
* nodes and edges stay byte-identical.
|
* nodes and chains stay byte-identical.
|
||||||
* The graph keeps the session's contributions; the
|
* The graph keeps the session's contributions; the
|
||||||
* snapshot simply no longer resolves the dead chatId to
|
* snapshot simply no longer resolves the dead chatId to
|
||||||
* a title (nodeSessions filters unknown ids at read
|
* a title (nodeSessions filters unknown ids at read
|
||||||
@@ -21,17 +27,16 @@
|
|||||||
*
|
*
|
||||||
* delete-nodes "this session never happened": every trace of the
|
* delete-nodes "this session never happened": every trace of the
|
||||||
* chat leaves the ledgers — its provenance points on
|
* chat leaves the ledgers — its provenance points on
|
||||||
* every node and edge line, its link lines (their
|
* every node and chain line, its link lines (their
|
||||||
* phrasings die with them), its coverage/checkpoint
|
* phrasings die with them), its coverage/checkpoint
|
||||||
* rows — and orphans die: a node
|
* rows — and orphans die: a node
|
||||||
* whose references all came from this chat disappears
|
* whose references all came from this chat disappears
|
||||||
* entirely, pruned from the positions and embedding
|
* entirely, pruned from the positions ledger too (dead
|
||||||
* ledgers too (dead members would break cluster hulls).
|
* members would break cluster hulls).
|
||||||
*
|
*
|
||||||
* Shared nodes survive automatically (the ruling): a node lives iff ANY
|
* Shared nodes survive automatically (the ruling): a node lives iff ANY
|
||||||
* surviving reference remains — a provenance point from another chat or
|
* surviving reference remains — a provenance point from another chat or
|
||||||
* notes source, or a surviving event (another session's quiz outcome /
|
* notes source. The create line of a surviving node is special:
|
||||||
* observation) naming it. The create line of a surviving node is special:
|
|
||||||
* name/statement/createdAt are the node's IDENTITY — the shared anchor
|
* name/statement/createdAt are the node's IDENTITY — the shared anchor
|
||||||
* other sessions' link verdicts recognized — so they stay even when the
|
* other sessions' link verdicts recognized — so they stay even when the
|
||||||
* deleted chat created the node; but that session's phrasings (the line's
|
* deleted chat created the node; but that session's phrasings (the line's
|
||||||
@@ -39,9 +44,6 @@
|
|||||||
* 2026-09-23: all traces, identity survives).
|
* 2026-09-23: all traces, identity survives).
|
||||||
*
|
*
|
||||||
* Not touched, by design:
|
* Not touched, by design:
|
||||||
* - cluster-labels.jsonl — immutable history by contract, and the
|
|
||||||
* namer is graph-global (tied to no session); core-survival renames
|
|
||||||
* communities whose core shrank below half.
|
|
||||||
* - imported-notes sources (src_…) — a different front door with its
|
* - imported-notes sources (src_…) — a different front door with its
|
||||||
* own lifecycle (re-import dedupes by path hash); this wave deletes
|
* own lifecycle (re-import dedupes by path hash); this wave deletes
|
||||||
* CHATS.
|
* CHATS.
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CoverageEntry,
|
CoverageEntry,
|
||||||
GraphEdge,
|
GraphChain,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
SessionCheckpoint,
|
SessionCheckpoint,
|
||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
@@ -71,8 +73,8 @@ export type ChatDeleteMode = "preserve-graph" | "delete-nodes";
|
|||||||
export interface ChatDeletionStats {
|
export interface ChatDeletionStats {
|
||||||
/** Distinct nodes that died entirely (all their lines). */
|
/** Distinct nodes that died entirely (all their lines). */
|
||||||
nodesRemoved: number;
|
nodesRemoved: number;
|
||||||
/** Edge assertion LINES removed (empty-provenance or dead endpoint). */
|
/** Chain assertion LINES removed (empty-provenance or dead member). */
|
||||||
edgesRemoved: number;
|
chainsRemoved: number;
|
||||||
/** Coverage rows removed. */
|
/** Coverage rows removed. */
|
||||||
coverageRemoved: number;
|
coverageRemoved: number;
|
||||||
/** Checkpoint lines removed. */
|
/** Checkpoint lines removed. */
|
||||||
@@ -81,7 +83,7 @@ export interface ChatDeletionStats {
|
|||||||
|
|
||||||
const EMPTY_STATS: ChatDeletionStats = {
|
const EMPTY_STATS: ChatDeletionStats = {
|
||||||
nodesRemoved: 0,
|
nodesRemoved: 0,
|
||||||
edgesRemoved: 0,
|
chainsRemoved: 0,
|
||||||
coverageRemoved: 0,
|
coverageRemoved: 0,
|
||||||
checkpointsRemoved: 0,
|
checkpointsRemoved: 0,
|
||||||
};
|
};
|
||||||
@@ -116,7 +118,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
|
|
||||||
// ── delete-nodes: the full purge ──────────────────────────────────────
|
// ── delete-nodes: the full purge ──────────────────────────────────────
|
||||||
const nodeLines = graphStore.readNodes();
|
const nodeLines = graphStore.readNodes();
|
||||||
const edgeLines = graphStore.readEdges();
|
const chainLines = graphStore.readChains();
|
||||||
|
|
||||||
// Does the chat touch the graph at all? (A never-extracted chat still
|
// Does the chat touch the graph at all? (A never-extracted chat still
|
||||||
// needs its coverage rows dropped above, but no ledger rewrite — keeps
|
// needs its coverage rows dropped above, but no ledger rewrite — keeps
|
||||||
@@ -124,7 +126,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
// changed.)
|
// changed.)
|
||||||
const touched =
|
const touched =
|
||||||
nodeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) ||
|
nodeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) ||
|
||||||
edgeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId));
|
chainLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId));
|
||||||
if (!touched) {
|
if (!touched) {
|
||||||
if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) {
|
if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) {
|
||||||
graphStore.rewriteLedgers({ coverage, checkpoints });
|
graphStore.rewriteLedgers({ coverage, checkpoints });
|
||||||
@@ -133,14 +135,12 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Survival: a node lives iff a reference from ANY surviving source
|
// 1. Survival: a node lives iff a reference from ANY surviving source
|
||||||
// remains — another chat/src's provenance point on any of its lines,
|
// remains — another chat/src's provenance point on any of its lines.
|
||||||
// a surviving mapping line (notes-front-door nodes carry EMPTY
|
// (Chain members are deliberately NOT survival references: a
|
||||||
// provenance: the file IS the anchor, recorded through the mapping
|
// chain's asserting session always left provenance on its endpoints,
|
||||||
// line). (Edge endpoints are deliberately
|
// so endpoint-only liveness cannot occur in a consistent ledger —
|
||||||
// NOT survival references: an edge's asserting session always left
|
// and counting it would keep nodes alive whose lines we would then
|
||||||
// provenance on its endpoints, so endpoint-only liveness cannot
|
// drop, dangling the chain.)
|
||||||
// occur in a consistent ledger — and counting it would keep nodes
|
|
||||||
// alive whose lines we would then drop, dangling the edge.)
|
|
||||||
const survives = (points: ReadonlyArray<{ chatId: string }>): boolean =>
|
const survives = (points: ReadonlyArray<{ chatId: string }>): boolean =>
|
||||||
points.some((pt) => pt.chatId !== chatId);
|
points.some((pt) => pt.chatId !== chatId);
|
||||||
const live = new Set<string>();
|
const live = new Set<string>();
|
||||||
@@ -182,41 +182,49 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
// else: the deleted chat's link line — dies with its phrasings.
|
// else: the deleted chat's link line — dies with its phrasings.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Edges: strip the chat's points; an assertion dies when its
|
// 3. Chains: strip the chat's points; an assertion dies when its
|
||||||
// provenance empties or either endpoint died IN THIS DELETION.
|
// provenance empties or ANY member node (premise or conclusion)
|
||||||
// (Only nodes that existed and died count — an edge whose endpoints
|
// died IN THIS DELETION. (Only nodes that existed and died count —
|
||||||
// never resolved as nodes is foreign data the deletion leaves alone:
|
// a chain whose members never resolved as nodes is foreign data the
|
||||||
// destructive rewriting may not exceed the deleted chat's blast
|
// deletion leaves alone: destructive rewriting may not exceed the
|
||||||
// radius.)
|
// deleted chat's blast radius.)
|
||||||
const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)));
|
const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)));
|
||||||
const outEdges: GraphEdge[] = [];
|
const outChains: GraphChain[] = [];
|
||||||
for (const line of edgeLines) {
|
for (const line of chainLines) {
|
||||||
if (deadNodes.has(line.from) || deadNodes.has(line.to)) continue;
|
if (deadNodes.has(line.conclusion) || line.premises.some((premise) => deadNodes.has(premise))) continue;
|
||||||
const points = line.provenance.filter((pt) => pt.chatId !== chatId);
|
const points = line.provenance.filter((pt) => pt.chatId !== chatId);
|
||||||
// An assertion dies when THIS deletion empties its provenance — a
|
// An assertion dies when THIS deletion empties its provenance — a
|
||||||
// line that already had no points is foreign/legacy data, not this
|
// line that already had no points is foreign/legacy data, not this
|
||||||
// chat's trace; it rides untouched.
|
// chat's trace; it rides untouched.
|
||||||
if (line.provenance.length > 0 && points.length === 0) continue;
|
if (line.provenance.length > 0 && points.length === 0) continue;
|
||||||
outEdges.push(points.length === line.provenance.length ? line : { ...line, provenance: points });
|
outChains.push(points.length === line.provenance.length ? line : { ...line, provenance: points });
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.nodesRemoved = [...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)).length;
|
stats.nodesRemoved = [...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)).length;
|
||||||
stats.edgesRemoved = edgeLines.length - outEdges.length;
|
stats.chainsRemoved = chainLines.length - outChains.length;
|
||||||
|
|
||||||
graphStore.rewriteLedgers({
|
graphStore.rewriteLedgers({
|
||||||
nodes: outNodes,
|
nodes: outNodes,
|
||||||
edges: outEdges,
|
chains: outChains,
|
||||||
coverage,
|
coverage,
|
||||||
checkpoints,
|
checkpoints,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Positions: prune dead nodes (stale resting spots would pin
|
// 4. Positions: prune dead nodes AND dead chains (an argument's derived
|
||||||
// nothing — but pruning keeps the ledger honest and the revision
|
// implication node carries the chain's arg_ id — stale resting spots
|
||||||
// moving for exactly the right reason).
|
// would pin nothing; pruning keeps the ledger honest and the
|
||||||
|
// revision moving for exactly the right reason).
|
||||||
|
const deadChainIds = new Set(
|
||||||
|
chainLines
|
||||||
|
.filter((line) => !outChains.includes(line))
|
||||||
|
.map((line) => line.id),
|
||||||
|
);
|
||||||
const positions = graphStore.readPositions();
|
const positions = graphStore.readPositions();
|
||||||
const knownNodeIds = new Set(nodeLines.map((line) => line.id));
|
const knownNodeIds = new Set(nodeLines.map((line) => line.id));
|
||||||
const prunedPositions = Object.fromEntries(
|
const prunedPositions = Object.fromEntries(
|
||||||
Object.entries(positions).filter(([id]) => live.has(id) || !knownNodeIds.has(id)),
|
Object.entries(positions).filter(
|
||||||
|
([id]) => (live.has(id) || !knownNodeIds.has(id)) && !deadChainIds.has(id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
|
if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
|
||||||
graphStore.writePositions(prunedPositions);
|
graphStore.writePositions(prunedPositions);
|
||||||
@@ -224,6 +232,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── single-node deletion (node interlude, owner-locked full-death) ───────────
|
// ─── single-node deletion (node interlude, owner-locked full-death) ───────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,13 +245,15 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
* one deletes a NODE and takes its every trace —
|
* one deletes a NODE and takes its every trace —
|
||||||
*
|
*
|
||||||
* - every line of the node (create + link lines)
|
* - every line of the node (create + link lines)
|
||||||
* - every EDGE touching it (from or to), whatever session asserted it
|
* - every CHAIN referencing it (as premise or conclusion), whatever
|
||||||
* - every mapping line that anchored it (the audit trail; for a
|
* session asserted it — a chain with a dead member is not a chain
|
||||||
* notes-born node the file IS the anchor — its mapping line is the
|
* - its resting position (and every dead chain's)
|
||||||
* only one)
|
|
||||||
* - its resting position
|
|
||||||
*
|
*
|
||||||
* The blast radius is exactly the rows naming the node id: other nodes'
|
* An ARGUMENT deletion is the same call with an arg_-prefixed id (owner,
|
||||||
|
* 2026-09-30: the chain table's fenced delete kills chains): the "node"
|
||||||
|
* is the derived implication node whose id IS the chain's id.
|
||||||
|
*
|
||||||
|
* The blast radius is exactly the rows naming the id: other nodes'
|
||||||
* provenance, aliases, and sessions are untouched (full death is
|
* provenance, aliases, and sessions are untouched (full death is
|
||||||
* NODE-scoped, not session-scoped), and the source records of notes
|
* NODE-scoped, not session-scoped), and the source records of notes
|
||||||
* imports stay (the import's bookkeeping; deleting one proposition is not
|
* imports stay (the import's bookkeeping; deleting one proposition is not
|
||||||
@@ -256,43 +267,68 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
|||||||
* second run finds no lines and changes nothing.
|
* second run finds no lines and changes nothing.
|
||||||
*/
|
*/
|
||||||
export interface NodeDeletionStats {
|
export interface NodeDeletionStats {
|
||||||
/** Edge assertion LINES removed (every edge touching the node). */
|
/** Chain assertion LINES removed (every chain referencing the node). */
|
||||||
edgesRemoved: number;
|
chainsRemoved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
|
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
|
||||||
return graphStore.readNodes().filter((line) => line.id === nodeId);
|
return graphStore.readNodes().filter((line) => line.id === nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chainLinesOf(graphStore: GraphStore, chainId: string): GraphChain[] {
|
||||||
|
return graphStore.readChains().filter((line) => line.id === chainId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-death one node. Returns null when no such node exists (the
|
* Full-death one node (or one argument, by its arg_ id). Returns null
|
||||||
* orchestration maps that to 404); rewrites only the ledgers that
|
* when no such node/chain exists (the orchestration maps that to 404);
|
||||||
* changed (unchanged files keep their mtimes — the version stamp moves
|
* rewrites only the ledgers that changed (unchanged files keep their
|
||||||
* only when something really did).
|
* mtimes — the version stamp moves only when something really did).
|
||||||
*/
|
*/
|
||||||
export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null {
|
export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null {
|
||||||
|
const chainLines = graphStore.readChains();
|
||||||
|
|
||||||
|
// The argument branch: an arg_ id names a CHAIN, not a proposition —
|
||||||
|
// its lines die, and that is the whole death (the member propositions
|
||||||
|
// survive: deleting an argument spares its premises/conclusion).
|
||||||
|
if (nodeId.startsWith("arg_")) {
|
||||||
|
const outChains = chainLines.filter((line) => line.id !== nodeId);
|
||||||
|
if (outChains.length === chainLines.length) return null; // unknown argument
|
||||||
|
graphStore.rewriteLedgers({ chains: outChains });
|
||||||
|
const positions = graphStore.readPositions();
|
||||||
|
if (positions[nodeId] !== undefined) {
|
||||||
|
const { [nodeId]: _dead, ...rest } = positions;
|
||||||
|
graphStore.writePositions(rest);
|
||||||
|
}
|
||||||
|
return { chainsRemoved: chainLines.length - outChains.length };
|
||||||
|
}
|
||||||
|
|
||||||
const nodeLines = nodeLinesOf(graphStore, nodeId);
|
const nodeLines = nodeLinesOf(graphStore, nodeId);
|
||||||
if (nodeLines.length === 0) return null;
|
if (nodeLines.length === 0) return null;
|
||||||
|
|
||||||
const edgeLines = graphStore.readEdges();
|
const outChains = chainLines.filter(
|
||||||
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId);
|
(line) => line.conclusion !== nodeId && !line.premises.includes(nodeId),
|
||||||
|
);
|
||||||
|
|
||||||
// Only the touched ledgers: the node's own lines always die (its file
|
// Only the touched ledgers: the node's own lines always die (its file
|
||||||
// must move), everything else rewrites only when rows actually drop.
|
// must move), everything else rewrites only when rows actually drop.
|
||||||
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
|
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
|
||||||
graphStore.rewriteLedgers({
|
graphStore.rewriteLedgers({
|
||||||
nodes: outNodes,
|
nodes: outNodes,
|
||||||
...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}),
|
...(outChains.length !== chainLines.length ? { chains: outChains } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// The resting position dies with the node.
|
// The resting positions die with the node and every dead chain.
|
||||||
|
const deadChainIds = new Set(chainLines.filter((line) => !outChains.includes(line)).map((line) => line.id));
|
||||||
const positions = graphStore.readPositions();
|
const positions = graphStore.readPositions();
|
||||||
if (positions[nodeId] !== undefined) {
|
const prunedPositions = Object.fromEntries(
|
||||||
const { [nodeId]: _dead, ...rest } = positions;
|
Object.entries(positions).filter(([id]) => id !== nodeId && !deadChainIds.has(id)),
|
||||||
graphStore.writePositions(rest);
|
);
|
||||||
|
if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
|
||||||
|
graphStore.writePositions(prunedPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
edgesRemoved: edgeLines.length - outEdges.length,
|
chainsRemoved: chainLines.length - outChains.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-45
@@ -45,7 +45,8 @@ import { randomBytes } from "node:crypto";
|
|||||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||||
import type {
|
import type {
|
||||||
AuditVerdicts,
|
AuditVerdicts,
|
||||||
ExtractedEdge,
|
ExtractedChain,
|
||||||
|
GraphChain,
|
||||||
SessionFindings,
|
SessionFindings,
|
||||||
Cluster,
|
Cluster,
|
||||||
EmissionStats,
|
EmissionStats,
|
||||||
@@ -56,6 +57,7 @@ import type {
|
|||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
|
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||||
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
||||||
|
import { formatChainLine } from "./practice.ts";
|
||||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||||
|
|
||||||
/** The storage ports every graph operation needs, passed explicitly. */
|
/** The storage ports every graph operation needs, passed explicitly. */
|
||||||
@@ -69,6 +71,18 @@ function newGraphNodeId(): string {
|
|||||||
return `n_${randomBytes(6).toString("hex")}`;
|
return `n_${randomBytes(6).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "arg_" + 12 hex chars — the implication-node namespace (a chain's id
|
||||||
|
* IS its derived node's id; collision-free with n_ by construction). */
|
||||||
|
function newGraphChainId(): string {
|
||||||
|
return `arg_${randomBytes(6).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chain's merge key: sorted premises + conclusion — the identity an
|
||||||
|
* assertion re-stating the same argument merges under. */
|
||||||
|
function chainKey(premises: ReadonlyArray<string>, conclusion: string): string {
|
||||||
|
return `${[...premises].sort().join("+")}>${conclusion}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** What one merge run merges FROM (the caller supplies it — the merger never
|
/** What one merge run merges FROM (the caller supplies it — the merger never
|
||||||
* reads the chat store): the coverage checkpoint for a session batch, or —
|
* reads the chat store): the coverage checkpoint for a session batch, or —
|
||||||
* for the format-agnostic notes front door (2026-09-13) — no coverage at
|
* for the format-agnostic notes front door (2026-09-13) — no coverage at
|
||||||
@@ -127,7 +141,7 @@ export function mergeFindings(
|
|||||||
source: MergeSource,
|
source: MergeSource,
|
||||||
): EmissionStats {
|
): EmissionStats {
|
||||||
const { graphStore: ledgers } = deps;
|
const { graphStore: ledgers } = deps;
|
||||||
const stats: EmissionStats = { nodes: 0, edges: 0 };
|
const stats: EmissionStats = { nodes: 0, chains: 0 };
|
||||||
|
|
||||||
// 1. Resolution: THIS job's propositions → graph node ids, built
|
// 1. Resolution: THIS job's propositions → graph node ids, built
|
||||||
// in-memory as the propositions resolve (owner, 2026-09-29: the
|
// in-memory as the propositions resolve (owner, 2026-09-29: the
|
||||||
@@ -180,31 +194,41 @@ export function mergeFindings(
|
|||||||
mapping.set(p.id, graphNodeId);
|
mapping.set(p.id, graphNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Edges — the extractor's own, the ONLY edge source (the auditor's
|
// 2. Chains — the extractor's own, the ONLY chain source; the stored
|
||||||
// edges argument is gone, owner 2026-09-28 evening). Endpoints are
|
// truth (chains-primary, owner 2026-09-30). Endpoints are this
|
||||||
// this job's local proposition ids and resolve through the job-local
|
// job's local proposition ids and resolve through the job-local
|
||||||
// map; edges carry the run's chat-level provenance point and dedupe
|
// map; chains carry the run's chat-level provenance point and
|
||||||
// by (from,to,type) already asserted by THIS chat — an assertion by
|
// dedupe by (premises, conclusion) already asserted by THIS chat —
|
||||||
// another chat stays untouched (provenance unions at read time).
|
// an assertion by another chat stays untouched (provenance unions
|
||||||
|
// at read time; a known argument re-stated appends under the SAME
|
||||||
|
// arg_ id — link-style, exactly like node link-appends; the first
|
||||||
|
// line's premises/createdAt stand at read time).
|
||||||
const assertedByChat = new Set<string>();
|
const assertedByChat = new Set<string>();
|
||||||
for (const line of ledgers.readEdges()) {
|
const idOfKey = new Map<string, string>();
|
||||||
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
for (const line of ledgers.readChains()) {
|
||||||
|
const key = chainKey(line.premises, line.conclusion);
|
||||||
|
if (!idOfKey.has(key)) idOfKey.set(key, line.id);
|
||||||
if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
|
if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
|
||||||
}
|
}
|
||||||
const emitEdge = (edge: ExtractedEdge): void => {
|
const emitChain = (chain: ExtractedChain): void => {
|
||||||
// Extractor-local ids only — the auditor's bridging edges are gone
|
|
||||||
// (owner, 2026-09-28 evening), so raw node-id endpoints no longer occur.
|
|
||||||
const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
|
const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
|
||||||
const from = resolve(edge.from);
|
const conclusion = resolve(chain.conclusion);
|
||||||
const to = resolve(edge.to);
|
if (conclusion === undefined) return; // unresolvable endpoint — skip
|
||||||
if (!from || !to || from === to) return; // unresolvable endpoint — skip
|
// Duplicate local ids resolving to one node (two phrasings of one
|
||||||
const key = `${from}\u0000${to}\u0000${edge.type}`;
|
// premise) collapse; a premise equal to the conclusion is dropped.
|
||||||
if (assertedByChat.has(key)) return;
|
const premises = [...new Set(chain.premises.map(resolve))].filter(
|
||||||
ledgers.appendEdge({ from, to, type: edge.type, provenance: pointOf(chatId) });
|
(p): p is string => p !== undefined && p !== conclusion,
|
||||||
|
);
|
||||||
|
if (premises.length === 0) return; // a chain IS an argument — bare propositions ride the node ledger alone
|
||||||
|
const key = chainKey(premises, conclusion);
|
||||||
|
if (assertedByChat.has(key)) return; // this chat already asserted this argument
|
||||||
|
const id = idOfKey.get(key) ?? newGraphChainId();
|
||||||
|
ledgers.appendChain({ id, premises, conclusion, createdAt: now, provenance: pointOf(chatId) });
|
||||||
|
idOfKey.set(key, id);
|
||||||
assertedByChat.add(key);
|
assertedByChat.add(key);
|
||||||
stats.edges++;
|
stats.chains!++;
|
||||||
};
|
};
|
||||||
for (const edge of findings.edges) emitEdge(edge); // the extractor's edges — the ONLY edge source
|
for (const chain of findings.chains) emitChain(chain); // the extractor's chains — the ONLY chain source // the extractor's edges — the ONLY edge source
|
||||||
|
|
||||||
// 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
|
// 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
|
||||||
// only now, after every append succeeded, is the batch covered. The
|
// only now, after every append succeeded, is the batch covered. The
|
||||||
@@ -222,21 +246,26 @@ export function mergeFindings(
|
|||||||
|
|
||||||
// ─── derivation (reads only — everything below is recomputable) ─────────────
|
// ─── derivation (reads only — everything below is recomputable) ─────────────
|
||||||
|
|
||||||
|
/** The merged STORED truth: propositions + chains (chains-primary, owner
|
||||||
|
* 2026-09-30). `nodes` are PROPOSITIONS ONLY — no derived implication
|
||||||
|
* nodes; the auditor's and prober's views see the world, not the
|
||||||
|
* rendering. The canvas-facing VIEW (implication nodes + untyped
|
||||||
|
* arrows) is deriveView() below — the lossy image. */
|
||||||
export interface DerivedGraph {
|
export interface DerivedGraph {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
chains: GraphChain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
||||||
* appends): the FIRST line defines name/statement/createdAt; later
|
* appends): the FIRST line defines name/statement/createdAt; later
|
||||||
* lines' statements and aliases join `aliases`, provenance unions — the
|
* lines' statements and aliases join `aliases`, provenance unions — the
|
||||||
* name never changes after creation. Edge
|
* name never changes after creation. Chain LINES are assertions, merged
|
||||||
* LINES are assertions, merged by (from,to,type) with unioned provenance,
|
* by (premises, conclusion) with unioned provenance; the first line's
|
||||||
* first-seen order. `store events, derive state` — this is the only reader
|
* arg_ id / premises / createdAt are the chain's identity.
|
||||||
* that matters.
|
* `store events, derive state` — this is the only reader that matters.
|
||||||
*/
|
*/
|
||||||
export function getGraph(graphStore: GraphStore): DerivedGraph {
|
export function getGraph(graphStore: Pick<GraphStore, "readNodes" | "readChains">): DerivedGraph {
|
||||||
// Merge node lines by id (first line defines; later lines add phrasings).
|
// Merge node lines by id (first line defines; later lines add phrasings).
|
||||||
const byId = new Map<string, GraphNode>();
|
const byId = new Map<string, GraphNode>();
|
||||||
for (const line of graphStore.readNodes()) {
|
for (const line of graphStore.readNodes()) {
|
||||||
@@ -254,29 +283,68 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged: GraphEdge[] = [];
|
// Merge chain assertion lines by (premises, conclusion): the first
|
||||||
const byKey = new Map<string, GraphEdge>();
|
// line's id/premises/createdAt stand, provenance unions.
|
||||||
for (const line of graphStore.readEdges()) {
|
const chains: GraphChain[] = [];
|
||||||
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
const byKey = new Map<string, GraphChain>();
|
||||||
|
for (const line of graphStore.readChains()) {
|
||||||
|
const key = chainKey(line.premises, line.conclusion);
|
||||||
const existing = byKey.get(key);
|
const existing = byKey.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
for (const pt of line.provenance) {
|
for (const pt of line.provenance) {
|
||||||
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
|
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const edge: GraphEdge = { ...line, provenance: [...line.provenance] };
|
const chain: GraphChain = { ...line, premises: [...line.premises], provenance: [...line.provenance] };
|
||||||
byKey.set(key, edge);
|
byKey.set(key, chain);
|
||||||
merged.push(edge);
|
chains.push(chain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { nodes: [...byId.values()], edges: merged };
|
return { nodes: [...byId.values()], chains };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The DERIVED view — what the canvas renders. One implication NODE per
|
||||||
|
* chain (id = the chain's arg_ id; name "⟹"; statement = the chain
|
||||||
|
* line; the chain's provenance/createdAt) plus UNTYPED arrows: each
|
||||||
|
* premise → the implication node, the implication node → the
|
||||||
|
* conclusion. Rendering specialization (invisible / physics-free)
|
||||||
|
* comes later (owner, 2026-09-30: physics is fine with imp nodes as
|
||||||
|
* regular nodes for now). 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[] } {
|
||||||
|
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));
|
||||||
|
const impNodes: GraphNode[] = [];
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
for (const chain of graph.chains) {
|
||||||
|
if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue;
|
||||||
|
impNodes.push({
|
||||||
|
id: chain.id,
|
||||||
|
name: "⟹",
|
||||||
|
statement: formatChainLine(chain, names),
|
||||||
|
aliases: [],
|
||||||
|
createdAt: chain.createdAt,
|
||||||
|
provenance: [...chain.provenance],
|
||||||
|
});
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
||||||
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||||
const { nodes, edges } = getGraph(deps.graphStore);
|
const graph = getGraph(deps.graphStore);
|
||||||
|
// The canvas-facing lossy image: propositions + derived implication
|
||||||
|
// nodes, untyped arrows. `chains` (the stored truth) ride along for
|
||||||
|
// the chain table and the practice pipeline — they read the truth,
|
||||||
|
// never the derived fan-out.
|
||||||
|
const view = deriveView(graph);
|
||||||
|
const nodes = view.nodes;
|
||||||
|
|
||||||
const nodeChats = new Map<string, Set<string>>();
|
const nodeChats = new Map<string, Set<string>>();
|
||||||
const touch = (nodeId: string, chatId: string): void => {
|
const touch = (nodeId: string, chatId: string): void => {
|
||||||
@@ -290,13 +358,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
for (const pt of node.provenance) touch(node.id, pt.chatId);
|
for (const pt of node.provenance) touch(node.id, pt.chatId);
|
||||||
}
|
}
|
||||||
for (const edge of edges) {
|
|
||||||
for (const pt of edge.provenance) {
|
|
||||||
touch(edge.from, pt.chatId);
|
|
||||||
touch(edge.to, pt.chatId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labels for every source the graph can point back at: real chats
|
// Labels for every source the graph can point back at: real chats
|
||||||
// (title, deep-linkable) and imported notes files (path, static — the
|
// (title, deep-linkable) and imported notes files (path, static — the
|
||||||
// format-agnostic front door's provenance).
|
// format-agnostic front door's provenance).
|
||||||
@@ -330,7 +391,9 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
// presentation rule (a region needs >= 3 propositions for a hull). A
|
// presentation rule (a region needs >= 3 propositions for a hull). A
|
||||||
// birth session whose record is gone (deleted chat) keeps its members
|
// birth session whose record is gone (deleted chat) keeps its members
|
||||||
// but renders unlabeled — honest, never guessed.
|
// but renders unlabeled — honest, never guessed.
|
||||||
const communities = birthSessionPartition(nodes);
|
// PROPOSITIONS ONLY (owner, 2026-09-30): derived implication nodes
|
||||||
|
// never partition — the stored world clusters, its image follows.
|
||||||
|
const communities = birthSessionPartition(graph.nodes);
|
||||||
const clusters: Cluster[] = communities
|
const clusters: Cluster[] = communities
|
||||||
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
|
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
|
||||||
.map((community) => ({
|
.map((community) => ({
|
||||||
@@ -346,7 +409,8 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges: view.edges,
|
||||||
|
chains: graph.chains,
|
||||||
sessions,
|
sessions,
|
||||||
fileSources,
|
fileSources,
|
||||||
nodeSessions,
|
nodeSessions,
|
||||||
|
|||||||
+17
-17
@@ -1,6 +1,7 @@
|
|||||||
import type { GraphEdge, GraphNode, Position } from "./contracts.ts";
|
import type { GraphChain, GraphNode, Position } from "./contracts.ts";
|
||||||
import { incrementalLayout } from "./layout.ts";
|
import { incrementalLayout } from "./layout.ts";
|
||||||
import { birthSessionPartition } from "./session-clusters.ts";
|
import { birthSessionPartition } from "./session-clusters.ts";
|
||||||
|
import { deriveView, getGraph } from "./graph.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The merge-time settle: recompute the resting layout from the CURRENT
|
* The merge-time settle: recompute the resting layout from the CURRENT
|
||||||
@@ -10,6 +11,12 @@ import { birthSessionPartition } from "./session-clusters.ts";
|
|||||||
* commit (client positions become the anchor set). Synchronous — it must
|
* commit (client positions become the anchor set). Synchronous — it must
|
||||||
* finish before the next /api/graph fetch reflects the change.
|
* finish before the next /api/graph fetch reflects the change.
|
||||||
*
|
*
|
||||||
|
* CHAINS-PRIMARY (owner, 2026-09-30): the settle lays out the DERIVED
|
||||||
|
* view — propositions AND the derived implication nodes (one per chain,
|
||||||
|
* arg_ ids) over the derived untyped arrows — so positions exist for
|
||||||
|
* every node the canvas renders. Implication nodes participate in the
|
||||||
|
* physics as regular nodes (rendering specialization comes later).
|
||||||
|
*
|
||||||
* SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
|
* SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
|
||||||
* browser barrel, and the settle needs the cluster partition for the
|
* browser barrel, and the settle needs the cluster partition for the
|
||||||
* cluster centroid pull — this module must not ship in the client bundle.
|
* cluster centroid pull — this module must not ship in the client bundle.
|
||||||
@@ -18,27 +25,20 @@ import { birthSessionPartition } from "./session-clusters.ts";
|
|||||||
*/
|
*/
|
||||||
export function settleGraphLayout(graphStore: {
|
export function settleGraphLayout(graphStore: {
|
||||||
readNodes(): GraphNode[];
|
readNodes(): GraphNode[];
|
||||||
readEdges(): GraphEdge[];
|
readChains(): GraphChain[];
|
||||||
readPositions(): Record<string, Position>;
|
readPositions(): Record<string, Position>;
|
||||||
writePositions(positions: Record<string, Position>): void;
|
writePositions(positions: Record<string, Position>): void;
|
||||||
}): void {
|
}): void {
|
||||||
const nodes = graphStore.readNodes();
|
const graph = getGraph(graphStore);
|
||||||
if (nodes.length === 0) return;
|
if (graph.nodes.length === 0) return;
|
||||||
// Node LINES may repeat an id (link-appends) — dedupe, first defines.
|
const view = deriveView(graph);
|
||||||
const seen = new Set<string>();
|
|
||||||
const unique = nodes.filter((n) => {
|
|
||||||
if (seen.has(n.id)) return false;
|
|
||||||
seen.add(n.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const edges = graphStore.readEdges();
|
|
||||||
const previous = new Map(Object.entries(graphStore.readPositions()));
|
const previous = new Map(Object.entries(graphStore.readPositions()));
|
||||||
// The cluster partition for the centroid pull: the SAME birth-session
|
// The cluster partition for the centroid pull: the SAME birth-session
|
||||||
// partition the snapshot renders (derived from provenance on read —
|
// partition the snapshot renders (derived from provenance on read —
|
||||||
// core/session-clusters.ts, wave 7) — one cluster concept everywhere.
|
// core/session-clusters.ts, wave 7) — one cluster concept everywhere.
|
||||||
// The unique list is the deduped set the partition needs (lines may
|
// PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never
|
||||||
// repeat an id — first defines).
|
// partition; the stored world clusters, its image follows.
|
||||||
const communities = birthSessionPartition(unique);
|
const communities = birthSessionPartition(graph.nodes);
|
||||||
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
|
const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities });
|
||||||
graphStore.writePositions(Object.fromEntries(next));
|
graphStore.writePositions(Object.fromEntries(next));
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-13
@@ -14,7 +14,7 @@ import type {
|
|||||||
ChatRecord,
|
ChatRecord,
|
||||||
ChatSettings,
|
ChatSettings,
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
GraphEdge,
|
GraphChain,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
JobKind,
|
JobKind,
|
||||||
JobRecord,
|
JobRecord,
|
||||||
@@ -65,11 +65,12 @@ export interface ChatStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
||||||
// v1 surface = the five JSONL ledgers (nodes / edges / events / mappings /
|
// Chains are the stored truth (owner, 2026-09-30): nodes + reasoning
|
||||||
// sessions). Reads return the raw append-order lines; merging edge assertions
|
// chains; edges are a DERIVED view (core/graph.ts) and never touch this
|
||||||
// by (from, to, type) is DERIVED in core/graph.ts — the store never decides.
|
// seam. Reads return the raw append-order lines; merging chain assertions
|
||||||
// The extraction pipeline (next stage) becomes the sole writer; this read
|
// by (premises, conclusion) is DERIVED in core/graph.ts — the store never
|
||||||
// surface stays.
|
// decides. The extraction pipeline is the sole writer; this read surface
|
||||||
|
// stays.
|
||||||
|
|
||||||
export interface GraphStore {
|
export interface GraphStore {
|
||||||
/** Append one GraphNode (ids are unique — no merging needed). */
|
/** Append one GraphNode (ids are unique — no merging needed). */
|
||||||
@@ -77,12 +78,10 @@ export interface GraphStore {
|
|||||||
/** Raw node lines in append order. */
|
/** Raw node lines in append order. */
|
||||||
readNodes(): GraphNode[];
|
readNodes(): GraphNode[];
|
||||||
|
|
||||||
/** Append one edge ASSERTION (a second session asserting the same
|
/** Append one chain ASSERTION (a second session asserting the same
|
||||||
* (from, to, type) appends another line; readers merge). */
|
* (premises, conclusion) appends another line; readers merge). */
|
||||||
appendEdge(assertion: GraphEdge): void;
|
appendChain(assertion: GraphChain): void;
|
||||||
readEdges(): GraphEdge[];
|
readChains(): GraphChain[];
|
||||||
|
|
||||||
/** Append one quiz outcome / understanding observation. */
|
|
||||||
|
|
||||||
/** Append one emission checkpoint. */
|
/** Append one emission checkpoint. */
|
||||||
appendCheckpoint(checkpoint: SessionCheckpoint): void;
|
appendCheckpoint(checkpoint: SessionCheckpoint): void;
|
||||||
@@ -122,7 +121,7 @@ export interface GraphStore {
|
|||||||
* replacement atomic. */
|
* replacement atomic. */
|
||||||
rewriteLedgers(next: {
|
rewriteLedgers(next: {
|
||||||
nodes?: GraphNode[];
|
nodes?: GraphNode[];
|
||||||
edges?: GraphEdge[];
|
chains?: GraphChain[];
|
||||||
coverage?: CoverageEntry[];
|
coverage?: CoverageEntry[];
|
||||||
checkpoints?: SessionCheckpoint[];
|
checkpoints?: SessionCheckpoint[];
|
||||||
}): void;
|
}): void;
|
||||||
|
|||||||
+26
-74
@@ -2,16 +2,15 @@
|
|||||||
* Practice-module core — the chain bijection + coverage (slice 1 of the
|
* Practice-module core — the chain bijection + coverage (slice 1 of the
|
||||||
* 2026-09-29 plan). Pure, no I/O.
|
* 2026-09-29 plan). Pure, no I/O.
|
||||||
*
|
*
|
||||||
* chainsForSelection(): the graph → chains translation — group by
|
* chainsForSelection(): the stored CHAINS (chains-primary, owner
|
||||||
* conclusion over FOLLOWS edges only (supports are invisible to practice,
|
* 2026-09-30) filtered to a selection — a chain rides along iff ALL of
|
||||||
* owner 2026-09-29: "no edges"). chainsToGraph(): the exact inverse — the
|
* its members are selected; nodes in no surviving chain become singleton
|
||||||
* bijection the chain notation promises (renderable back to the graph
|
* chains. formatChainLine(): one world-facing line per chain.
|
||||||
* with no information loss; the round-trip is pinned in
|
* chainCoverage(): the set's completeness law — the union of the
|
||||||
* tests/practice.test.ts). chainCoverage(): the set's completeness law —
|
* problems' chains must equal the set's chains; the partitioner's submit
|
||||||
* the union of the problems' chains must equal the set's chains; the
|
* is validated against it.
|
||||||
* partitioner's submit is validated against it.
|
|
||||||
*/
|
*/
|
||||||
import type { CueDistance, GraphEdge, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts";
|
import type { CueDistance, GraphChain, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts";
|
||||||
|
|
||||||
/** The named levels (owner-approved 2026-09-29) — definitions verbatim
|
/** The named levels (owner-approved 2026-09-29) — definitions verbatim
|
||||||
* from the ladder doctrine / the contract's tables. The UI's stepped
|
* from the ladder doctrine / the contract's tables. The UI's stepped
|
||||||
@@ -36,81 +35,34 @@ export const PRESENTATIONS: Record<Presentation, { name: string; definition: str
|
|||||||
application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
|
application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The selection's follows-edges as chains: every edge into a node joins
|
/** The stored chains filtered to a selection (chains-primary, owner
|
||||||
* that node's (one) chain; a node with no in- AND no out-follows-edge in
|
* 2026-09-30): a chain rides along iff its conclusion AND every premise
|
||||||
* the selection is a singleton chain. Premise-only nodes are NOT separate
|
* are selected; a node in no surviving chain is a singleton chain
|
||||||
* chains — they are covered inside the chains they feed. Deterministic:
|
* (premises: []). Premise-only nodes whose chain was excluded are
|
||||||
* chains sorted by id, premises sorted. */
|
* singletons — the same inclusion law as the pre-chains edge grouping.
|
||||||
|
* Every stored chain keeps its OWN id (arg_…) — two independent
|
||||||
|
* arguments for one conclusion stay distinct. Deterministic: chains
|
||||||
|
* sorted by id, premises sorted. */
|
||||||
export function chainsForSelection(
|
export function chainsForSelection(
|
||||||
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
|
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
|
||||||
edges: ReadonlyArray<Pick<GraphEdge, "from" | "to" | "type">>,
|
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
|
||||||
): ReasoningChain[] {
|
): ReasoningChain[] {
|
||||||
const selected = new Set(nodes.map((n) => n.id));
|
const selected = new Set(nodes.map((n) => n.id));
|
||||||
const premisesByConclusion = new Map<string, Set<string>>();
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (edge.type !== "follows") continue; // supports are NO edges here (owner, 2026-09-29)
|
|
||||||
if (!selected.has(edge.from) || !selected.has(edge.to)) continue;
|
|
||||||
const premises = premisesByConclusion.get(edge.to) ?? new Set<string>();
|
|
||||||
premises.add(edge.from);
|
|
||||||
premisesByConclusion.set(edge.to, premises);
|
|
||||||
}
|
|
||||||
const chained = new Set<string>();
|
const chained = new Set<string>();
|
||||||
const chains: ReasoningChain[] = [];
|
const included: ReasoningChain[] = [];
|
||||||
for (const [conclusion, premises] of premisesByConclusion) {
|
for (const chain of chains) {
|
||||||
chains.push({ id: conclusion, premises: [...premises].sort(), conclusion });
|
if (!selected.has(chain.conclusion)) continue;
|
||||||
chained.add(conclusion);
|
if (!chain.premises.every((premise) => selected.has(premise))) continue;
|
||||||
for (const premise of premises) chained.add(premise);
|
included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion });
|
||||||
|
chained.add(chain.conclusion);
|
||||||
|
for (const premise of chain.premises) chained.add(premise);
|
||||||
}
|
}
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!chained.has(node.id)) {
|
if (!chained.has(node.id)) {
|
||||||
chains.push({ id: node.id, premises: [], conclusion: node.id });
|
included.push({ id: node.id, premises: [], conclusion: node.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chains.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||||
}
|
|
||||||
|
|
||||||
/** The exact inverse of chainsForSelection: node ids = every chain member,
|
|
||||||
* edges = one follows-edge per (premise, conclusion) pair. Deterministic:
|
|
||||||
* node ids sorted, edges sorted by (from, to). */
|
|
||||||
export function chainsToGraph(
|
|
||||||
chains: ReadonlyArray<Pick<ReasoningChain, "premises" | "conclusion">>,
|
|
||||||
): {
|
|
||||||
nodeIds: string[];
|
|
||||||
edges: Array<{ from: string; to: string; type: "follows" }>;
|
|
||||||
} {
|
|
||||||
const nodeIds = new Set<string>();
|
|
||||||
const edges: Array<{ from: string; to: string; type: "follows" }> = [];
|
|
||||||
for (const chain of chains) {
|
|
||||||
nodeIds.add(chain.conclusion);
|
|
||||||
for (const premise of chain.premises) {
|
|
||||||
nodeIds.add(premise);
|
|
||||||
edges.push({ from: premise, to: chain.conclusion, type: "follows" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
edges.sort((a, b) => (a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.to < b.to ? -1 : 1));
|
|
||||||
return { nodeIds: [...nodeIds].sort(), edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The extraction seam's chain→edge expansion (extractor redesign,
|
|
||||||
* 2026-09-30): the extractor SUBMITS chains (premises + conclusion);
|
|
||||||
* the pipeline's internal findings carry follows-EDGES downstream
|
|
||||||
* (auditor, merger, coverage — all unchanged). Each premise becomes
|
|
||||||
* one edge into the conclusion — chainsToGraph's edge law, shared
|
|
||||||
* (one math, one place). Identical (premise-set, conclusion) chains
|
|
||||||
* dedupe SILENTLY here: the model restating an argument is a no-op,
|
|
||||||
* never an error (validation errors are the loud channel; see the
|
|
||||||
* extractor's validate hook). */
|
|
||||||
export function chainsToEdges(
|
|
||||||
chains: ReadonlyArray<Pick<ReasoningChain, "premises" | "conclusion">>,
|
|
||||||
): Array<{ from: string; to: string; type: "follows" }> {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const deduped = chains.filter((chain) => {
|
|
||||||
const key = `${[...chain.premises].sort().join("+")}>${chain.conclusion}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return chainsToGraph(deduped).edges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A chain as one world-facing line — the propositions' NAMES joined
|
/** A chain as one world-facing line — the propositions' NAMES joined
|
||||||
|
|||||||
@@ -54,19 +54,6 @@ export interface GraphRenderSettings {
|
|||||||
labelZoom: number;
|
labelZoom: number;
|
||||||
/** Node + hover label font size, WORLD units (scales with camera). */
|
/** Node + hover label font size, WORLD units (scales with camera). */
|
||||||
labelSize: number;
|
labelSize: number;
|
||||||
/** Draw `supports` edges at all (owner, 2026-09-29): off hides them
|
|
||||||
* from the canvas — a DRAW toggle only. The physics sim still feels
|
|
||||||
* them (settings never shift the graph: camera, one frame), and node
|
|
||||||
* detail cards still list them. */
|
|
||||||
showSupports: boolean;
|
|
||||||
/** Relative SHAFT thickness of supports edges (owner, 2026-09-29):
|
|
||||||
* a 0–1 multiplier of edgeWidth — default 0.5, the single-line
|
|
||||||
* relation reads as the lighter one. SHAFT ONLY: arrowheads keep
|
|
||||||
* full Arrowhead-size geometry (owner pick). Linear, not a ladder —
|
|
||||||
* it's a ratio; exactly 0 floors to a hairline in the renderer
|
|
||||||
* (canvas cannot stroke a true 0-width line; hiding is the toggle's
|
|
||||||
* job). */
|
|
||||||
supportsWidth: number;
|
|
||||||
/** Toggle for the cluster region labels (hulls always draw). */
|
/** Toggle for the cluster region labels (hulls always draw). */
|
||||||
clusterLabels: boolean;
|
clusterLabels: boolean;
|
||||||
/** Cluster label font size, px on screen (camera-compensated —
|
/** Cluster label font size, px on screen (camera-compensated —
|
||||||
@@ -108,14 +95,6 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings {
|
|||||||
arrowLength: legacySizes ? DEFAULT_RENDER_SETTINGS.arrowLength : ladd(r.arrowLength, ARROW_LENGTH_LADDER, DEFAULT_RENDER_SETTINGS.arrowLength),
|
arrowLength: legacySizes ? DEFAULT_RENDER_SETTINGS.arrowLength : ladd(r.arrowLength, ARROW_LENGTH_LADDER, DEFAULT_RENDER_SETTINGS.arrowLength),
|
||||||
labelZoom: ladd(r.labelZoom, LABEL_ZOOM_LADDER, DEFAULT_RENDER_SETTINGS.labelZoom),
|
labelZoom: ladd(r.labelZoom, LABEL_ZOOM_LADDER, DEFAULT_RENDER_SETTINGS.labelZoom),
|
||||||
labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize),
|
labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize),
|
||||||
showSupports:
|
|
||||||
typeof r.showSupports === "boolean" ? r.showSupports : DEFAULT_RENDER_SETTINGS.showSupports,
|
|
||||||
// Linear ratio, not a ladder: real numbers clamp to [0, 1]; anything
|
|
||||||
// else resets to the default.
|
|
||||||
supportsWidth:
|
|
||||||
typeof r.supportsWidth === "number" && Number.isFinite(r.supportsWidth)
|
|
||||||
? clamp(r.supportsWidth, 0, 1)
|
|
||||||
: DEFAULT_RENDER_SETTINGS.supportsWidth,
|
|
||||||
clusterLabels:
|
clusterLabels:
|
||||||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
||||||
clusterLabelSize: ladd(
|
clusterLabelSize: ladd(
|
||||||
@@ -283,8 +262,6 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
|
|||||||
arrowLength: 2,
|
arrowLength: 2,
|
||||||
labelZoom: 4,
|
labelZoom: 4,
|
||||||
labelSize: 3,
|
labelSize: 3,
|
||||||
showSupports: true,
|
|
||||||
supportsWidth: 0.5,
|
|
||||||
clusterLabels: true,
|
clusterLabels: true,
|
||||||
clusterLabelSize: 14,
|
clusterLabelSize: 14,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ export function graphRevision(): number {
|
|||||||
let revision = 0;
|
let revision = 0;
|
||||||
for (const file of [
|
for (const file of [
|
||||||
LEDGER_FILES.nodes,
|
LEDGER_FILES.nodes,
|
||||||
LEDGER_FILES.edges,
|
LEDGER_FILES.chains,
|
||||||
LEDGER_FILES.sessions,
|
LEDGER_FILES.sessions,
|
||||||
LEDGER_FILES.coverage,
|
LEDGER_FILES.coverage,
|
||||||
POSITIONS_FILE,
|
POSITIONS_FILE,
|
||||||
|
|||||||
+46
-31
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* GraphStore — the fs JSONL adapter: four append-only ledgers at
|
* GraphStore — the fs JSONL adapter: the append-only ledgers at
|
||||||
* config.LEDGER_FILES (contracts.ts "v1 emission").
|
* config.LEDGER_FILES (contracts.ts "v1 emission").
|
||||||
* nodes.jsonl GraphNode — one line per node write: the FIRST
|
* nodes.jsonl GraphNode — one line per node write: the FIRST
|
||||||
* line for an id defines it; a later
|
* line for an id defines it; a later
|
||||||
@@ -7,10 +7,18 @@
|
|||||||
* link-append (session phrasings join
|
* link-append (session phrasings join
|
||||||
* aliases, provenance unions); reads
|
* aliases, provenance unions); reads
|
||||||
* merge by id (core/graph.ts getGraph)
|
* merge by id (core/graph.ts getGraph)
|
||||||
* edges.jsonl GraphEdge — one line per edge ASSERTION (a second
|
* chains.jsonl GraphChain — one line per chain ASSERTION (a second
|
||||||
* session asserting the same (from,to,type)
|
* session asserting the same
|
||||||
* appends another line; reads merge)
|
* (premises, conclusion) appends
|
||||||
|
* another line; readers merge)
|
||||||
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt)
|
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt)
|
||||||
|
* coverage.jsonl CoverageEntry — per-message extraction coverage
|
||||||
|
*
|
||||||
|
* CHAINS-PRIMARY (owner, 2026-09-30): there is NO edges ledger — the edge
|
||||||
|
* view (implication nodes + untyped arrows) is derived in core/graph.ts on
|
||||||
|
* every read and never persisted. A stale edges.jsonl left by a pre-chains
|
||||||
|
* build is deleted once, here, at adapter load (same doctrine as the
|
||||||
|
* events-ledger purge below).
|
||||||
*
|
*
|
||||||
* Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping
|
* Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping
|
||||||
* (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write
|
* (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write
|
||||||
@@ -28,7 +36,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
||||||
import type {
|
import type {
|
||||||
GraphEdge,
|
GraphChain,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
ExtractionRunUsage,
|
ExtractionRunUsage,
|
||||||
NotesSource,
|
NotesSource,
|
||||||
@@ -94,15 +102,20 @@ const isGraphNode = (v: unknown): v is GraphNode =>
|
|||||||
Array.isArray(v.provenance) &&
|
Array.isArray(v.provenance) &&
|
||||||
v.provenance.every(isProvenancePoint);
|
v.provenance.every(isProvenancePoint);
|
||||||
|
|
||||||
const EDGE_TYPES = ["follows", "supports"] as const;
|
/** A chain line: premises ≥ 1 (a chain IS an argument — bare propositions
|
||||||
|
* ride the node ledger alone), every member a string, conclusion a string.
|
||||||
const isGraphEdge = (v: unknown): v is GraphEdge =>
|
* Endpoint EXISTENCE is the derivation's concern (it drops dangling
|
||||||
|
* chains); the store only shapes. */
|
||||||
|
const isGraphChain = (v: unknown): v is GraphChain =>
|
||||||
isRecord(v) &&
|
isRecord(v) &&
|
||||||
isString(v.from) &&
|
isString(v.id) &&
|
||||||
isString(v.to) &&
|
Array.isArray(v.premises) &&
|
||||||
(EDGE_TYPES as readonly string[]).includes(v.type as string) &&
|
v.premises.length > 0 &&
|
||||||
|
v.premises.every(isString) &&
|
||||||
|
isString(v.conclusion) &&
|
||||||
Array.isArray(v.provenance) &&
|
Array.isArray(v.provenance) &&
|
||||||
v.provenance.every(isProvenancePoint);
|
v.provenance.every(isProvenancePoint) &&
|
||||||
|
isString(v.createdAt);
|
||||||
|
|
||||||
const isCheckpoint = (v: unknown): v is SessionCheckpoint =>
|
const isCheckpoint = (v: unknown): v is SessionCheckpoint =>
|
||||||
isRecord(v) && isString(v.chatId) && isString(v.foldedAt);
|
isRecord(v) && isString(v.chatId) && isString(v.foldedAt);
|
||||||
@@ -123,12 +136,13 @@ export const graphStore: GraphStore = {
|
|||||||
return readValid(LEDGER_FILES.nodes, isGraphNode);
|
return readValid(LEDGER_FILES.nodes, isGraphNode);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */
|
/** Append one chain ASSERTION; readers merge lines by (premises,
|
||||||
appendEdge(assertion: GraphEdge): void {
|
* conclusion). */
|
||||||
appendLine(LEDGER_FILES.edges, assertion);
|
appendChain(assertion: GraphChain): void {
|
||||||
|
appendLine(LEDGER_FILES.chains, assertion);
|
||||||
},
|
},
|
||||||
readEdges(): GraphEdge[] {
|
readChains(): GraphChain[] {
|
||||||
return readValid(LEDGER_FILES.edges, isGraphEdge);
|
return readValid(LEDGER_FILES.chains, isGraphChain);
|
||||||
},
|
},
|
||||||
|
|
||||||
appendCheckpoint(checkpoint: SessionCheckpoint): void {
|
appendCheckpoint(checkpoint: SessionCheckpoint): void {
|
||||||
@@ -184,13 +198,13 @@ export const graphStore: GraphStore = {
|
|||||||
* as "nothing yet", same as never-emitted. */
|
* as "nothing yet", same as never-emitted. */
|
||||||
rewriteLedgers(next: {
|
rewriteLedgers(next: {
|
||||||
nodes?: GraphNode[];
|
nodes?: GraphNode[];
|
||||||
edges?: GraphEdge[];
|
chains?: GraphChain[];
|
||||||
coverage?: CoverageEntry[];
|
coverage?: CoverageEntry[];
|
||||||
checkpoints?: SessionCheckpoint[];
|
checkpoints?: SessionCheckpoint[];
|
||||||
}): void {
|
}): void {
|
||||||
const files = {
|
const files = {
|
||||||
nodes: LEDGER_FILES.nodes,
|
nodes: LEDGER_FILES.nodes,
|
||||||
edges: LEDGER_FILES.edges,
|
chains: LEDGER_FILES.chains,
|
||||||
coverage: LEDGER_FILES.coverage,
|
coverage: LEDGER_FILES.coverage,
|
||||||
checkpoints: LEDGER_FILES.sessions,
|
checkpoints: LEDGER_FILES.sessions,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -290,20 +304,21 @@ function writeSourceMap(map: Record<string, NotesSource>): void {
|
|||||||
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
|
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── one-way migration: the events ledger is gone (2026-09-27) ────────────────
|
// ─── one-way purges at adapter load: dead ledgers are exactly the confusion
|
||||||
// Quiz outcomes and understanding observations are no longer formalized in
|
// the cleanup removes (owner doctrine — nothing reads them, nothing writes
|
||||||
// the graph — the pipeline writes nodes + edges only. A stale events.jsonl
|
// them, a stale file lying around is a lie). ────────────────────────────────
|
||||||
// left by an earlier build is deleted once, here, at adapter load: dead
|
|
||||||
// ledgers are exactly the confusion the cleanup removes (owner ruling).
|
|
||||||
{
|
{
|
||||||
const legacyEvents = path.join(path.dirname(LEDGER_FILES.nodes), "events.jsonl");
|
const graphDir = path.dirname(LEDGER_FILES.nodes);
|
||||||
try {
|
for (const legacy of ["edges.jsonl", "events.jsonl"]) {
|
||||||
if (fs.existsSync(legacyEvents)) {
|
const file = path.join(graphDir, legacy);
|
||||||
fs.rmSync(legacyEvents);
|
try {
|
||||||
console.info(`[graph-store] purged legacy events ledger: ${legacyEvents}`);
|
if (fs.existsSync(file)) {
|
||||||
|
fs.rmSync(file);
|
||||||
|
console.info(`[graph-store] purged legacy ledger: ${file}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[graph-store] could not purge legacy ledger ${file}:`, err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[graph-store] could not purge legacy events ledger:`, err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-53
@@ -33,7 +33,7 @@ import type {
|
|||||||
AuditVerdicts,
|
AuditVerdicts,
|
||||||
CoverageEntry,
|
CoverageEntry,
|
||||||
ExtractionRunUsage,
|
ExtractionRunUsage,
|
||||||
GraphEdge,
|
GraphChain,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
|
|
||||||
NotesSource,
|
NotesSource,
|
||||||
@@ -74,7 +74,7 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
|||||||
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
|
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
|
||||||
function memoryLedgers() {
|
function memoryLedgers() {
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
const edges: GraphEdge[] = [];
|
const chains: GraphChain[] = [];
|
||||||
const checkpoints: SessionCheckpoint[] = [];
|
const checkpoints: SessionCheckpoint[] = [];
|
||||||
const coverage: CoverageEntry[] = [];
|
const coverage: CoverageEntry[] = [];
|
||||||
const sources = new Map<string, NotesSource>();
|
const sources = new Map<string, NotesSource>();
|
||||||
@@ -82,8 +82,8 @@ function memoryLedgers() {
|
|||||||
return {
|
return {
|
||||||
appendNode: (n: GraphNode) => void nodes.push(n),
|
appendNode: (n: GraphNode) => void nodes.push(n),
|
||||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
readNodes: () => nodes.map((n) => ({ ...n })),
|
||||||
appendEdge: (e: GraphEdge) => void edges.push(e),
|
appendChain: (c: GraphChain) => void chains.push(c),
|
||||||
readEdges: () => edges.map((e) => ({ ...e })),
|
readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
|
||||||
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
||||||
readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
|
readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
|
||||||
// Extraction coverage (vision-extraction-coverage.md) — append-only;
|
// Extraction coverage (vision-extraction-coverage.md) — append-only;
|
||||||
@@ -114,7 +114,7 @@ function memoryLedgers() {
|
|||||||
// store: replace the provided ledgers' full contents.
|
// store: replace the provided ledgers' full contents.
|
||||||
rewriteLedgers: (next) => {
|
rewriteLedgers: (next) => {
|
||||||
if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })));
|
if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })));
|
||||||
if (next.edges) edges.splice(0, edges.length, ...next.edges.map((e) => ({ ...e, provenance: [...e.provenance] })));
|
if (next.chains) chains.splice(0, chains.length, ...next.chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })));
|
||||||
if (next.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c })));
|
if (next.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c })));
|
||||||
if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.map((c) => ({ ...c })));
|
if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.map((c) => ({ ...c })));
|
||||||
},
|
},
|
||||||
@@ -386,7 +386,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
|
|
||||||
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
|
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
|
||||||
const stats = await pipeline.runExtraction(chatId);
|
const stats = await pipeline.runExtraction(chatId);
|
||||||
assert.deepEqual(stats, { nodes: 2, edges: 1 });
|
assert.deepEqual(stats, { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stringified-payload heal: the CHAINS array arriving as ONE JSON string still validates (redesign, 2026-09-30)", async () => {
|
it("stringified-payload heal: the CHAINS array arriving as ONE JSON string still validates (redesign, 2026-09-30)", async () => {
|
||||||
@@ -402,7 +402,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
extractor,
|
extractor,
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => {
|
it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => {
|
||||||
@@ -436,7 +436,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
// p1 creates no node, p2 creates one, and the extractor's edge
|
// p1 creates no node, p2 creates one, and the extractor's edge
|
||||||
// resolves through them. (The old echo form could silently drop
|
// resolves through them. (The old echo form could silently drop
|
||||||
// content by omitting it; that accidental drop channel is gone.)
|
// content by omitting it; that accidental drop channel is gone.)
|
||||||
assert.deepEqual(stats, { nodes: 1, edges: 1 });
|
assert.deepEqual(stats, { nodes: 1, chains: 1 });
|
||||||
|
|
||||||
// Link-append: the phrasing joined the existing node's aliases and
|
// Link-append: the phrasing joined the existing node's aliases and
|
||||||
// the provenance unioned; p2's create line is its own node.
|
// the provenance unioned; p2's create line is its own node.
|
||||||
@@ -544,7 +544,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
auditor: submitModel("submit_review", verdict),
|
auditor: submitModel("submit_review", verdict),
|
||||||
});
|
});
|
||||||
const stats = await pipeline.runExtraction(chatId);
|
const stats = await pipeline.runExtraction(chatId);
|
||||||
assert.deepEqual(stats, { nodes: 2, edges: 1 }); // full findings merge (verdicts-only)
|
assert.deepEqual(stats, { nodes: 2, chains: 1 }); // full findings merge (verdicts-only)
|
||||||
// Wave 7 (950ddfd): birth-session clusters carry session-name labels,
|
// Wave 7 (950ddfd): birth-session clusters carry session-name labels,
|
||||||
// so the post-merge naming garnish is gone — no model spend, no
|
// so the post-merge naming garnish is gone — no model spend, no
|
||||||
// naming-run records, no label writes.
|
// naming-run records, no label writes.
|
||||||
@@ -609,7 +609,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
});
|
});
|
||||||
// The old run-level throw is gone: one failed job is absorbed at
|
// The old run-level throw is gone: one failed job is absorbed at
|
||||||
// the fan-out — the run resolves with what actually merged (zeros).
|
// the fan-out — the run resolves with what actually merged (zeros).
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 });
|
||||||
assert.equal(ledgers.readNodes().length, 0);
|
assert.equal(ledgers.readNodes().length, 0);
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs");
|
assert.equal(ledgers.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs");
|
||||||
assert.equal(ledgers.readCoverage().length, 0);
|
assert.equal(ledgers.readCoverage().length, 0);
|
||||||
@@ -620,7 +620,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
extractor: silentModel(),
|
extractor: silentModel(),
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await failingExtractor.runExtraction(chatId), { nodes: 0, edges: 0 });
|
assert.deepEqual(await failingExtractor.runExtraction(chatId), { nodes: 0, chains: 0 });
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||||
|
|
||||||
// THE isolation pin: a two-batch chat whose SECOND extractor call
|
// THE isolation pin: a two-batch chat whose SECOND extractor call
|
||||||
@@ -642,7 +642,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
engineFor: (role) =>
|
engineFor: (role) =>
|
||||||
role === "extractor" ? fauxEngine([countingStep]) : submitModel("submit_review", { links: [] }),
|
role === "extractor" ? fauxEngine([countingStep]) : submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await mixed.runExtraction(chunkyId), { nodes: 2, edges: 1 }, "batch 1's merge survives batch 2's failure");
|
assert.deepEqual(await mixed.runExtraction(chunkyId), { nodes: 2, chains: 1 }, "batch 1's merge survives batch 2's failure");
|
||||||
assert.equal(invocations, 2);
|
assert.equal(invocations, 2);
|
||||||
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -731,8 +731,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
assert.equal(extractionStatuses().find((r) => r.chatId === a.chatId)?.phase, "auditing", "A still audits while B waits");
|
assert.equal(extractionStatuses().find((r) => r.chatId === a.chatId)?.phase, "auditing", "A still audits while B waits");
|
||||||
|
|
||||||
gateOpen();
|
gateOpen();
|
||||||
assert.deepEqual(await runA, { nodes: 1, edges: 0 });
|
assert.deepEqual(await runA, { nodes: 1, chains: 0 });
|
||||||
assert.deepEqual(await runB, { nodes: 1, edges: 0 });
|
assert.deepEqual(await runB, { nodes: 1, chains: 0 });
|
||||||
assert.ok(bAuditorCalled);
|
assert.ok(bAuditorCalled);
|
||||||
assert.ok(bSawAlphasMerge, "B's auditor saw A's merged node — audit serialized after merge");
|
assert.ok(bSawAlphasMerge, "B's auditor saw A's merged node — audit serialized after merge");
|
||||||
assert.equal(extractionStatuses().length, 0, "both runs left the registry");
|
assert.equal(extractionStatuses().length, 0, "both runs left the registry");
|
||||||
@@ -774,7 +774,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
|
|
||||||
// The dock's abort button, pressed while B queues.
|
// The dock's abort button, pressed while B queues.
|
||||||
assert.equal(abortExtraction(b.chatId), true);
|
assert.equal(abortExtraction(b.chatId), true);
|
||||||
assert.deepEqual(await runB, { nodes: 0, edges: 0 }, "aborted before auditing — zero emissions");
|
assert.deepEqual(await runB, { nodes: 0, chains: 0 }, "aborted before auditing — zero emissions");
|
||||||
assert.ok(!bAuditorCalled, "B's auditor was never invoked");
|
assert.ok(!bAuditorCalled, "B's auditor was never invoked");
|
||||||
assert.ok(!extractionStatuses().some((r) => r.chatId === b.chatId), "B left the registry");
|
assert.ok(!extractionStatuses().some((r) => r.chatId === b.chatId), "B left the registry");
|
||||||
assert.equal(ledgers.readCoverage().some((row) => row.chatId === b.chatId), false, "B checkpointed nothing");
|
assert.equal(ledgers.readCoverage().some((row) => row.chatId === b.chatId), false, "B checkpointed nothing");
|
||||||
@@ -782,14 +782,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
// The chain link B abandoned still resolved: A finishes, and a
|
// The chain link B abandoned still resolved: A finishes, and a
|
||||||
// THIRD run audits normally — no deadlock residue.
|
// THIRD run audits normally — no deadlock residue.
|
||||||
gateOpen();
|
gateOpen();
|
||||||
assert.deepEqual(await runA, { nodes: 1, edges: 0 });
|
assert.deepEqual(await runA, { nodes: 1, chains: 0 });
|
||||||
const pipelineC = createExtractionPipeline({
|
const pipelineC = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")),
|
extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")),
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipelineC.runExtraction(c.chatId), { nodes: 1, edges: 0 });
|
assert.deepEqual(await pipelineC.runExtraction(c.chatId), { nodes: 1, chains: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures per-stage usage live, persists the run's usage into the chat record", async () => {
|
it("captures per-stage usage live, persists the run's usage into the chat record", async () => {
|
||||||
@@ -862,7 +862,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const stats = await runPromise;
|
const stats = await runPromise;
|
||||||
|
|
||||||
// A deliberate stop, not a failure: zero emissions, no throw.
|
// A deliberate stop, not a failure: zero emissions, no throw.
|
||||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
assert.deepEqual(stats, { nodes: 0, chains: 0 });
|
||||||
assert.equal(extractionStatuses().length, 0, "the registry clears");
|
assert.equal(extractionStatuses().length, 0, "the registry clears");
|
||||||
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed — the next trigger re-runs");
|
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed — the next trigger re-runs");
|
||||||
@@ -907,13 +907,13 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
|
|
||||||
const first = pipeline.runExtraction(chatId);
|
const first = pipeline.runExtraction(chatId);
|
||||||
const second = await pipeline.runExtraction(chatId); // in-flight → zeros
|
const second = await pipeline.runExtraction(chatId); // in-flight → zeros
|
||||||
assert.deepEqual(second, { nodes: 0, edges: 0 });
|
assert.deepEqual(second, { nodes: 0, chains: 0 });
|
||||||
|
|
||||||
release();
|
release();
|
||||||
// The gated extractor never submits — an ISOLATED job failure now
|
// The gated extractor never submits — an ISOLATED job failure now
|
||||||
// (per-chunk jobs, owner 2026-09-28): the run resolves with zeros
|
// (per-chunk jobs, owner 2026-09-28): the run resolves with zeros
|
||||||
// instead of throwing.
|
// instead of throwing.
|
||||||
assert.deepEqual(await first, { nodes: 0, edges: 0 });
|
assert.deepEqual(await first, { nodes: 0, chains: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state", async () => {
|
it("registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state", async () => {
|
||||||
@@ -959,10 +959,10 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
);
|
);
|
||||||
const g = globalThis as { __lhExtractionRuns?: { running: Map<string, unknown> } };
|
const g = globalThis as { __lhExtractionRuns?: { running: Map<string, unknown> } };
|
||||||
assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs");
|
assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs");
|
||||||
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0 }, "single-flight spans pipeline instances");
|
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, chains: 0 }, "single-flight spans pipeline instances");
|
||||||
|
|
||||||
release();
|
release();
|
||||||
assert.deepEqual(await first, { nodes: 0, edges: 0 }, "isolated job failure — the run resolves zeros");
|
assert.deepEqual(await first, { nodes: 0, chains: 0 }, "isolated job failure — the run resolves zeros");
|
||||||
assert.ok(!extractionStatuses().some((run) => run.chatId === chatId), "the registry entry clears when the run ends (however it ends)");
|
assert.ok(!extractionStatuses().some((run) => run.chatId === chatId), "the registry entry clears when the run ends (however it ends)");
|
||||||
assert.ok(!g.__lhExtractionRuns?.running.has(chatId), "nothing leaks into the global registry");
|
assert.ok(!g.__lhExtractionRuns?.running.has(chatId), "nothing leaks into the global registry");
|
||||||
});
|
});
|
||||||
@@ -1027,7 +1027,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("co-premises fan out at the seam: ONE chain with two premises becomes TWO follows-edges into the conclusion", async () => {
|
it("co-premises ride as ONE stored argument — the fan-out to arrows is the derived view, not the store", async () => {
|
||||||
const { chatId } = fixtureChat();
|
const { chatId } = fixtureChat();
|
||||||
const ledgers = memoryLedgers();
|
const ledgers = memoryLedgers();
|
||||||
const extractor = submitModel("submit_findings", {
|
const extractor = submitModel("submit_findings", {
|
||||||
@@ -1044,16 +1044,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
extractor,
|
extractor,
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, edges: 2 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, chains: 1 });
|
||||||
// Both edges point INTO the conclusion's resolved node; the two
|
// The stored chain's premises are exactly the two premises; the
|
||||||
// from-ends are exactly the two premises (node ids are random —
|
// conclusion is C's resolved node (ids are random — assert
|
||||||
// assert set-wise, never by ledger order).
|
// set-wise, never by ledger order).
|
||||||
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
|
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
|
||||||
const conclusion = byName.get("C")!;
|
const conclusion = byName.get("C")!;
|
||||||
const edges = ledgers.readEdges();
|
const chains = ledgers.readChains();
|
||||||
assert.ok(edges.every((e) => e.to === conclusion), "both edges point INTO the conclusion");
|
assert.ok(chains.every((c) => c.conclusion === conclusion), "chains point INTO the conclusion");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
edges.map((e) => e.from).sort(),
|
chains.flatMap((c) => c.premises).sort(),
|
||||||
[byName.get("A")!, byName.get("B")!].sort(),
|
[byName.get("A")!, byName.get("B")!].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1079,11 +1079,11 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
extractor,
|
extractor,
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
// Independent arguments for the same conclusion are separate chains
|
// Independent arguments for the same conclusion are separate chains;
|
||||||
// → two edges into C; the bare p4 rides the propositions list alone.
|
// the bare p4 rides the propositions list alone.
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, edges: 2 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, chains: 2 });
|
||||||
const lonely = ledgers.readNodes().find((n) => n.name === "Lonely")!;
|
const lonely = ledgers.readNodes().find((n) => n.name === "Lonely")!;
|
||||||
assert.ok(ledgers.readEdges().every((e) => e.from !== lonely.id && e.to !== lonely.id));
|
assert.ok(ledgers.readChains().every((c) => !c.premises.includes(lonely.id) && c.conclusion !== lonely.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("identical chains dedupe SILENTLY at the expansion — restating an argument is a no-op, not an error", async () => {
|
it("identical chains dedupe SILENTLY at the expansion — restating an argument is a no-op, not an error", async () => {
|
||||||
@@ -1099,7 +1099,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
extractor,
|
extractor,
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the OLD wire shape (an edges array, no chains) is dead: validation fails every turn — nothing merges", async () => {
|
it("the OLD wire shape (an edges array, no chains) is dead: validation fails every turn — nothing merges", async () => {
|
||||||
@@ -1127,7 +1127,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
engineFor: (role) => (role === "extractor" ? oldShape() : submitModel("submit_review", { links: [] })),
|
engineFor: (role) => (role === "extractor" ? oldShape() : submitModel("submit_review", { links: [] })),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validate error-back: a chain referencing an UNKNOWN id is rejected, the model re-submits clean", async () => {
|
it("validate error-back: a chain referencing an UNKNOWN id is rejected, the model re-submits clean", async () => {
|
||||||
@@ -1145,7 +1145,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
});
|
});
|
||||||
// The first submit was schema-VALID — only the chain law caught it;
|
// The first submit was schema-VALID — only the chain law caught it;
|
||||||
// the retry merges in full.
|
// the retry merges in full.
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validate error-back: a conclusion in its OWN premises is rejected, the model re-submits clean", async () => {
|
it("validate error-back: a conclusion in its OWN premises is rejected, the model re-submits clean", async () => {
|
||||||
@@ -1161,7 +1161,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
extractor: retryEngine(bad, FINDINGS),
|
extractor: retryEngine(bad, FINDINGS),
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty premises never pass: the schema's minItems rejects, the model re-submits clean", async () => {
|
it("empty premises never pass: the schema's minItems rejects, the model re-submits clean", async () => {
|
||||||
@@ -1177,7 +1177,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
|||||||
extractor: retryEngine(bad, FINDINGS),
|
extractor: retryEngine(bad, FINDINGS),
|
||||||
auditor: submitModel("submit_review", { links: [] }),
|
auditor: submitModel("submit_review", { links: [] }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the system prompt is goal-first: ONE shared core, both preambles assembled onto it", () => {
|
it("the system prompt is goal-first: ONE shared core, both preambles assembled onto it", () => {
|
||||||
@@ -1226,7 +1226,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
});
|
});
|
||||||
const stats = await pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
const stats = await pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||||
|
|
||||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
assert.deepEqual(stats, { nodes: 1, chains: 0 });
|
||||||
// The run surfaced as a NOTES run labeled with the file path.
|
// The run surfaced as a NOTES run labeled with the file path.
|
||||||
assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]);
|
assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]);
|
||||||
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
|
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
|
||||||
@@ -1290,7 +1290,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
// independently (owner, 2026-09-29 — the mappings ledger is gone);
|
// independently (owner, 2026-09-29 — the mappings ledger is gone);
|
||||||
// cross-chunk identity is the auditor's linkTo verdict, and these
|
// cross-chunk identity is the auditor's linkTo verdict, and these
|
||||||
// fixtures submit none.
|
// fixtures submit none.
|
||||||
assert.deepEqual(stats, { nodes: calls, edges: 0 });
|
assert.deepEqual(stats, { nodes: calls, chains: 0 });
|
||||||
assert.equal(ledgers.readNodes().length, calls);
|
assert.equal(ledgers.readNodes().length, calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1319,7 +1319,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
});
|
});
|
||||||
// Isolated job failure (per-chunk jobs, owner 2026-09-28): the run
|
// Isolated job failure (per-chunk jobs, owner 2026-09-28): the run
|
||||||
// resolves zeros — no throw — and still nothing merged.
|
// resolves zeros — no throw — and still nothing merged.
|
||||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 });
|
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 });
|
||||||
assert.equal(ledgers.readNodes().length, 0);
|
assert.equal(ledgers.readNodes().length, 0);
|
||||||
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
|
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
|
||||||
});
|
});
|
||||||
@@ -1334,13 +1334,13 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
auditor: submitModel("submit_review", NOTES_VERDICT),
|
auditor: submitModel("submit_review", NOTES_VERDICT),
|
||||||
});
|
});
|
||||||
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||||
assert.deepEqual(first, { nodes: 1, edges: 0 });
|
assert.deepEqual(first, { nodes: 1, chains: 0 });
|
||||||
|
|
||||||
// Unchanged text: the content fingerprint matches — no extraction
|
// Unchanged text: the content fingerprint matches — no extraction
|
||||||
// at all (no model calls, no usage record; the mappings ledger
|
// at all (no model calls, no usage record; the mappings ledger
|
||||||
// used to give this no-op at merge time by accident).
|
// used to give this no-op at merge time by accident).
|
||||||
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||||
assert.deepEqual(second, { nodes: 0, edges: 0 }, "unchanged content is a true no-op");
|
assert.deepEqual(second, { nodes: 0, chains: 0 }, "unchanged content is a true no-op");
|
||||||
|
|
||||||
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
|
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
|
||||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "only the real run billed its usage");
|
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "only the real run billed its usage");
|
||||||
@@ -1350,7 +1350,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
// whole file re-runs (fresh merge; the auditor's links would dedupe
|
// whole file re-runs (fresh merge; the auditor's links would dedupe
|
||||||
// identity on a real model).
|
// identity on a real model).
|
||||||
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` });
|
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` });
|
||||||
assert.deepEqual(third, { nodes: 1, edges: 0 }, "changed content re-extracts");
|
assert.deepEqual(third, { nodes: 1, chains: 0 }, "changed content re-extracts");
|
||||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage");
|
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage");
|
||||||
assert.equal(ledgers.readNodes().length, 2);
|
assert.equal(ledgers.readNodes().length, 2);
|
||||||
});
|
});
|
||||||
@@ -1367,7 +1367,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
|
|
||||||
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
|
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
|
||||||
assert.equal(abortExtraction(srcId), true);
|
assert.equal(abortExtraction(srcId), true);
|
||||||
assert.deepEqual(await runPromise, { nodes: 0, edges: 0 });
|
assert.deepEqual(await runPromise, { nodes: 0, chains: 0 });
|
||||||
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
|
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
|
||||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
|
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
|
||||||
@@ -1462,7 +1462,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
// SEPARATE job's namespace (owner, 2026-09-29 — no mapping table)
|
// SEPARATE job's namespace (owner, 2026-09-29 — no mapping table)
|
||||||
// — its propositions merge again as fresh nodes. Cross-batch
|
// — its propositions merge again as fresh nodes. Cross-batch
|
||||||
// identity is the auditor's linkTo verdict, never the merger's.
|
// identity is the auditor's linkTo verdict, never the merger's.
|
||||||
assert.deepEqual(stats, { nodes: 4, edges: 2 });
|
assert.deepEqual(stats, { nodes: 4, chains: 2 });
|
||||||
|
|
||||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||||
const coverage = ledgers.readCoverage();
|
const coverage = ledgers.readCoverage();
|
||||||
@@ -1474,7 +1474,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
// new usage entry (no money spent on covered ground).
|
// new usage entry (no money spent on covered ground).
|
||||||
const before = views.length;
|
const before = views.length;
|
||||||
const again = await pipeline.runExtraction(chatId);
|
const again = await pipeline.runExtraction(chatId);
|
||||||
assert.deepEqual(again, { nodes: 0, edges: 0 });
|
assert.deepEqual(again, { nodes: 0, chains: 0 });
|
||||||
assert.equal(views.length, before);
|
assert.equal(views.length, before);
|
||||||
// PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job
|
// PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job
|
||||||
// (two), and the no-op run adds none.
|
// (two), and the no-op run adds none.
|
||||||
@@ -1521,7 +1521,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
const stats = await runPromise;
|
const stats = await runPromise;
|
||||||
|
|
||||||
// Batch 1's emissions are kept and its messages stay covered.
|
// Batch 1's emissions are kept and its messages stay covered.
|
||||||
assert.deepEqual(stats, { nodes: 2, edges: 1 });
|
assert.deepEqual(stats, { nodes: 2, chains: 1 });
|
||||||
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||||
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
|
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
|
||||||
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
||||||
@@ -1654,7 +1654,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
|
|||||||
|
|
||||||
// SOURCE-WIDE abort: every chunk job of the fan-out dies together.
|
// SOURCE-WIDE abort: every chunk job of the fan-out dies together.
|
||||||
assert.equal(abortExtraction(srcId), true);
|
assert.equal(abortExtraction(srcId), true);
|
||||||
assert.deepEqual(await runPromise, { nodes: 0, edges: 0 });
|
assert.deepEqual(await runPromise, { nodes: 0, chains: 0 });
|
||||||
assert.equal(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
|
assert.equal(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
|
||||||
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
||||||
// Per-job usage: all 8 chunk jobs persisted their (failed) spend.
|
// Per-job usage: all 8 chunk jobs persisted their (failed) spend.
|
||||||
@@ -1706,7 +1706,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
|
|||||||
// Identical findings in every chunk, one merge PER chunk (jobs are
|
// Identical findings in every chunk, one merge PER chunk (jobs are
|
||||||
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
|
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
|
||||||
// 8 × 1 edge. The auditor's links, not the merger, dedupe identity.
|
// 8 × 1 edge. The auditor's links, not the merger, dedupe identity.
|
||||||
assert.deepEqual(stats, { nodes: 16, edges: 8 });
|
assert.deepEqual(stats, { nodes: 16, chains: 8 });
|
||||||
assert.equal(ledgers.readNodes().length, 16);
|
assert.equal(ledgers.readNodes().length, 16);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1730,12 +1730,12 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
|||||||
// emissions (stats present but empty — nothing merged).
|
// emissions (stats present but empty — nothing merged).
|
||||||
assert.ok(stats);
|
assert.ok(stats);
|
||||||
assert.equal(stats.nodes, 0);
|
assert.equal(stats.nodes, 0);
|
||||||
assert.equal(stats.edges, 0);
|
assert.equal(stats.chains, 0);
|
||||||
|
|
||||||
// Nothing merged, nothing checkpointed — the deleted chat leaves no
|
// Nothing merged, nothing checkpointed — the deleted chat leaves no
|
||||||
// trace for a later snapshot to resurrect.
|
// trace for a later snapshot to resurrect.
|
||||||
assert.equal(ledgers.readNodes().length, 0);
|
assert.equal(ledgers.readNodes().length, 0);
|
||||||
assert.equal(ledgers.readEdges().length, 0);
|
assert.equal(ledgers.readChains().length, 0);
|
||||||
assert.equal(ledgers.readCoverage().length, 0);
|
assert.equal(ledgers.readCoverage().length, 0);
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { createProberTools } from "../src/agent/tools.ts";
|
|||||||
import { auditorGraphTools } from "../src/agent/tools/graph.ts";
|
import { auditorGraphTools } from "../src/agent/tools/graph.ts";
|
||||||
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
|
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
|
||||||
import type { ChatSummary } from "../src/core/contracts/chat.ts";
|
import type { ChatSummary } from "../src/core/contracts/chat.ts";
|
||||||
import type { GraphEdge, GraphNode } from "../src/core/contracts/graph.ts";
|
import type { GraphChain, GraphNode } from "../src/core/contracts/graph.ts";
|
||||||
|
|
||||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,10 +39,10 @@ function fakeStores(
|
|||||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||||
} = {},
|
} = {},
|
||||||
): { chatStore: ChatStore; graphStore: GraphStore } {
|
): { chatStore: ChatStore; graphStore: GraphStore } {
|
||||||
const edges: GraphEdge[] = [];
|
const chains: GraphChain[] = [];
|
||||||
const graphStore: GraphStore = {
|
const graphStore: GraphStore = {
|
||||||
readNodes: () => nodes,
|
readNodes: () => nodes,
|
||||||
readEdges: () => edges,
|
readChains: () => chains,
|
||||||
readCheckpoints: () => [],
|
readCheckpoints: () => [],
|
||||||
readCoverage: () => [],
|
readCoverage: () => [],
|
||||||
readSources: () => [],
|
readSources: () => [],
|
||||||
|
|||||||
+56
-51
@@ -243,11 +243,10 @@ function fixtureFindings(): SessionFindings {
|
|||||||
aliases: [],
|
aliases: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [
|
chains: [
|
||||||
{
|
{
|
||||||
from: "p1",
|
premises: ["p1"],
|
||||||
to: "p2",
|
conclusion: "p2",
|
||||||
type: "follows",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -274,14 +273,14 @@ function scopedGraph(chatIds: string[]) {
|
|||||||
const graph = getGraph(graphStore);
|
const graph = getGraph(graphStore);
|
||||||
return {
|
return {
|
||||||
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
||||||
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
chains: graph.chains.filter((c) => c.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("the merger (mergeFindings)", () => {
|
describe("the merger (mergeFindings)", () => {
|
||||||
it("creates nodes from the verdicts with chat-level provenance", () => {
|
it("creates nodes from the verdicts with chat-level provenance", () => {
|
||||||
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id));
|
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id));
|
||||||
assert.deepEqual(stats, { nodes: 3, edges: 1 });
|
assert.deepEqual(stats, { nodes: 3, chains: 1 });
|
||||||
|
|
||||||
const graph = scopedGraph([chat1.id]);
|
const graph = scopedGraph([chat1.id]);
|
||||||
assert.equal(graph.nodes.length, 3);
|
assert.equal(graph.nodes.length, 3);
|
||||||
@@ -298,13 +297,13 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
|
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
|
||||||
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||||
|
|
||||||
// The follows edge, provenance attached.
|
// The stored chain, provenance attached.
|
||||||
assert.equal(graph.edges.length, 1);
|
assert.equal(graph.chains.length, 1);
|
||||||
const edge = graph.edges[0]!;
|
const chain = graph.chains[0]!;
|
||||||
assert.equal(edge.from, idByStatement.get("Alpha intuition"));
|
assert.match(chain.id, /^arg_/, "store-minted argument id");
|
||||||
assert.equal(edge.to, idByStatement.get("Beta rule"));
|
assert.deepEqual(chain.premises, [idByStatement.get("Alpha intuition")]);
|
||||||
assert.equal(edge.type, "follows");
|
assert.equal(chain.conclusion, idByStatement.get("Beta rule"));
|
||||||
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
assert.deepEqual(chain.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,15 +320,15 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
{ id: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
|
{ id: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
|
||||||
{ id: "p2", name: "Rep two", statement: "Independence two statement", aliases: [] },
|
{ id: "p2", name: "Rep two", statement: "Independence two statement", aliases: [] },
|
||||||
],
|
],
|
||||||
edges: [{ from: "p1", to: "p2", type: "follows" }],
|
chains: [{ premises: ["p1"], conclusion: "p2" }],
|
||||||
};
|
};
|
||||||
const first = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
const first = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
||||||
assert.deepEqual(first, { nodes: 2, edges: 1 });
|
assert.deepEqual(first, { nodes: 2, chains: 1 });
|
||||||
const second = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
const second = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
|
||||||
assert.deepEqual(second, { nodes: 2, edges: 1 }, "nothing remembered — everything re-creates");
|
assert.deepEqual(second, { nodes: 2, chains: 1 }, "nothing remembered — everything re-creates");
|
||||||
const graph = scopedGraph([rep.id]);
|
const graph = scopedGraph([rep.id]);
|
||||||
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes");
|
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes");
|
||||||
assert.equal(graph.edges.length, 2, "fresh endpoints — fresh edge assertions");
|
assert.equal(graph.chains.length, 2, "fresh endpoints — fresh chain assertions");
|
||||||
// exactly one checkpoint line per merge, though — checkpoints are a log
|
// exactly one checkpoint line per merge, though — checkpoints are a log
|
||||||
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2);
|
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2);
|
||||||
});
|
});
|
||||||
@@ -341,10 +340,10 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
||||||
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
|
||||||
],
|
],
|
||||||
edges: [],
|
chains: [],
|
||||||
};
|
};
|
||||||
const stats = mergeFindings(dup.id, findings, { links: [] }, graphDeps, sessionSource(dup.id));
|
const stats = mergeFindings(dup.id, findings, { links: [] }, graphDeps, sessionSource(dup.id));
|
||||||
assert.deepEqual(stats, { nodes: 1, edges: 0 }, "the duplicate id is skipped — first resolution stands");
|
assert.deepEqual(stats, { nodes: 1, chains: 0 }, "the duplicate id is skipped — first resolution stands");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
|
it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
|
||||||
@@ -373,7 +372,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
aliases: ["alpha intuition"],
|
aliases: ["alpha intuition"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
chains: [],
|
||||||
},
|
},
|
||||||
// VERDICTS-ONLY: the identity call is a link entry, not a field
|
// VERDICTS-ONLY: the identity call is a link entry, not a field
|
||||||
// on the proposition (owner, 2026-09-28).
|
// on the proposition (owner, 2026-09-28).
|
||||||
@@ -381,7 +380,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
graphDeps,
|
graphDeps,
|
||||||
sessionSource(chat2.id),
|
sessionSource(chat2.id),
|
||||||
);
|
);
|
||||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
assert.deepEqual(stats, { nodes: 0, chains: 0 });
|
||||||
|
|
||||||
const after = scopedGraph([chat1.id, chat2.id]);
|
const after = scopedGraph([chat1.id, chat2.id]);
|
||||||
assert.equal(after.nodes.length, 3, "no duplicate node");
|
assert.equal(after.nodes.length, 3, "no duplicate node");
|
||||||
@@ -410,7 +409,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
aliases: [],
|
aliases: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
chains: [],
|
||||||
},
|
},
|
||||||
{ links: [] },
|
{ links: [] },
|
||||||
graphDeps,
|
graphDeps,
|
||||||
@@ -439,10 +438,9 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
statement,
|
statement,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
})),
|
})),
|
||||||
edges: [
|
chains: [
|
||||||
{ from: "p1", to: "p2", type: "follows", },
|
{ premises: ["p1"], conclusion: "p2", },
|
||||||
{ from: "p2", to: "p3", type: "follows", },
|
{ premises: ["p2"], conclusion: "p3", },
|
||||||
{ from: "p1", to: "p3", type: "follows", },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ links: [] },
|
{ links: [] },
|
||||||
@@ -494,7 +492,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
aliases: [],
|
aliases: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
chains: [],
|
||||||
},
|
},
|
||||||
// empty verdicts — identity is the auditor's call alone
|
// empty verdicts — identity is the auditor's call alone
|
||||||
{ links: [] },
|
{ links: [] },
|
||||||
@@ -519,13 +517,13 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
aliases: [],
|
aliases: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
chains: [],
|
||||||
},
|
},
|
||||||
{ links: [] },
|
{ links: [] },
|
||||||
graphDeps,
|
graphDeps,
|
||||||
{ foldedAt: "2026-09-13T00:00:00.000Z" },
|
{ foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||||
);
|
);
|
||||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
assert.deepEqual(stats, { nodes: 1, chains: 0 });
|
||||||
|
|
||||||
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
|
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
|
||||||
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
|
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
|
||||||
@@ -617,7 +615,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
|
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
|
||||||
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], },
|
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], },
|
||||||
],
|
],
|
||||||
edges: [{ from: "p1", to: "p2", type: "follows", }],
|
chains: [{ premises: ["p1"], conclusion: "p2", }],
|
||||||
};
|
};
|
||||||
mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
|
mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
|
||||||
// Resolve through THIS session's provenance — the shared ledger
|
// Resolve through THIS session's provenance — the shared ledger
|
||||||
@@ -642,7 +640,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"] },
|
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"] },
|
||||||
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], },
|
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], },
|
||||||
],
|
],
|
||||||
edges: [{ from: "q1", to: "q2", type: "follows", }],
|
chains: [{ premises: ["q1"], conclusion: "q2", }],
|
||||||
};
|
};
|
||||||
mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
|
mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
|
||||||
const zId = bornOf(dB.id, "B extra statement");
|
const zId = bornOf(dB.id, "B extra statement");
|
||||||
@@ -665,7 +663,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
const stats = deleteChatTraces(graphStore, dA.id, "delete-nodes");
|
const stats = deleteChatTraces(graphStore, dA.id, "delete-nodes");
|
||||||
assert.deepEqual(stats, {
|
assert.deepEqual(stats, {
|
||||||
nodesRemoved: 1,
|
nodesRemoved: 1,
|
||||||
edgesRemoved: 1,
|
chainsRemoved: 1,
|
||||||
coverageRemoved: 2,
|
coverageRemoved: 2,
|
||||||
checkpointsRemoved: 1,
|
checkpointsRemoved: 1,
|
||||||
} satisfies ChatDeletionStats);
|
} satisfies ChatDeletionStats);
|
||||||
@@ -684,9 +682,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
|
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
|
||||||
// Z (dB-only) is untouched, byte-identical.
|
// Z (dB-only) is untouched, byte-identical.
|
||||||
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore);
|
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore);
|
||||||
// Edges: dA's assertion X→Y died; dB's X→Z survived.
|
// Chains: dA's argument X⇒Y died (Y died); dB's X⇒Z survived.
|
||||||
const scoped = scopedGraph([dA.id, dB.id]);
|
const scoped = scopedGraph([dA.id, dB.id]);
|
||||||
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]);
|
assert.equal(scoped.chains.length, 1);
|
||||||
|
assert.deepEqual(scoped.chains[0]!.premises, [xId]);
|
||||||
|
assert.equal(scoped.chains[0]!.conclusion, zId);
|
||||||
|
assert.deepEqual(scoped.chains[0]!.provenance, [{ chatId: dB.id, messageId: "" }]);
|
||||||
// Positions: the orphan pruned.
|
// Positions: the orphan pruned.
|
||||||
const positions = graphStore.readPositions();
|
const positions = graphStore.readPositions();
|
||||||
assert.equal(positions[yId], undefined);
|
assert.equal(positions[yId], undefined);
|
||||||
@@ -703,25 +704,25 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
|
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
|
||||||
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
|
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
|
||||||
// Idempotent: a second run finds nothing.
|
// Idempotent: a second run finds nothing.
|
||||||
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, chainsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
|
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
|
||||||
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
|
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
|
||||||
const before = {
|
const before = {
|
||||||
nodes: graphStore.readNodes(),
|
nodes: graphStore.readNodes(),
|
||||||
edges: graphStore.readEdges(),
|
chains: graphStore.readChains(),
|
||||||
positions: graphStore.readPositions(),
|
positions: graphStore.readPositions(),
|
||||||
};
|
};
|
||||||
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
|
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
|
||||||
assert.deepEqual(stats, {
|
assert.deepEqual(stats, {
|
||||||
nodesRemoved: 0,
|
nodesRemoved: 0,
|
||||||
edgesRemoved: 0,
|
chainsRemoved: 0,
|
||||||
coverageRemoved: 2,
|
coverageRemoved: 2,
|
||||||
checkpointsRemoved: 1,
|
checkpointsRemoved: 1,
|
||||||
} satisfies ChatDeletionStats);
|
} satisfies ChatDeletionStats);
|
||||||
assert.deepEqual(graphStore.readNodes(), before.nodes);
|
assert.deepEqual(graphStore.readNodes(), before.nodes);
|
||||||
assert.deepEqual(graphStore.readEdges(), before.edges);
|
assert.deepEqual(graphStore.readChains(), before.chains);
|
||||||
assert.deepEqual(graphStore.readPositions(), before.positions);
|
assert.deepEqual(graphStore.readPositions(), before.positions);
|
||||||
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
|
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
|
||||||
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true);
|
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true);
|
||||||
@@ -769,12 +770,12 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
|||||||
/** The full-death scenario: node X is born in a notes file (EMPTY
|
/** The full-death scenario: node X is born in a notes file (EMPTY
|
||||||
* provenance — a legacy line; the mappings anchor is gone, owner
|
* provenance — a legacy line; the mappings anchor is gone, owner
|
||||||
* 2026-09-29), re-encountered by a chat (link line: alias +
|
* 2026-09-29), re-encountered by a chat (link line: alias +
|
||||||
* provenance), and asserted in edges by two sessions. */
|
* provenance), and asserted in chains by two sessions. */
|
||||||
function buildNodeScenario() {
|
function buildNodeScenario() {
|
||||||
// Clean slate per test: each test in this describe builds its own
|
// Clean slate per test: each test in this describe builds its own
|
||||||
// scenario from empty ledgers (the suite's isolation covers the
|
// scenario from empty ledgers (the suite's isolation covers the
|
||||||
// whole file; this covers describe-internal stacking).
|
// whole file; this covers describe-internal stacking).
|
||||||
graphStore.rewriteLedgers({ nodes: [], edges: [] });
|
graphStore.rewriteLedgers({ nodes: [], chains: [] });
|
||||||
graphStore.writePositions({});
|
graphStore.writePositions({});
|
||||||
graphStore.appendNode({
|
graphStore.appendNode({
|
||||||
id: "n_dead",
|
id: "n_dead",
|
||||||
@@ -802,18 +803,20 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
|||||||
createdAt: "2026-09-23T00:00:00.000Z",
|
createdAt: "2026-09-23T00:00:00.000Z",
|
||||||
provenance: [{ chatId: "chat_node_a", messageId: "m2" }],
|
provenance: [{ chatId: "chat_node_a", messageId: "m2" }],
|
||||||
});
|
});
|
||||||
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
|
graphStore.appendChain({ id: "arg_dead_keep", premises: ["n_dead"], conclusion: "n_keep", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
|
||||||
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
graphStore.appendChain({ id: "arg_keep_dead", premises: ["n_keep"], conclusion: "n_dead", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||||
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
// A FOREIGN chain the deletion must not touch (destructive rewriting
|
||||||
// Resting positions for both.
|
// may not exceed the blast radius — foreign data rides untouched).
|
||||||
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
|
graphStore.appendChain({ id: "arg_keep_other", premises: ["n_keep"], conclusion: "n_other", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||||
|
// Resting positions for both nodes and both dying chains' imp nodes.
|
||||||
|
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 }, arg_dead_keep: { x: 1.5, y: 1.5 }, arg_keep_dead: { x: 2.5, y: 2.5 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
|
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
|
||||||
buildNodeScenario();
|
buildNodeScenario();
|
||||||
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
||||||
const stats = deleteNodeTraces(graphStore, "n_dead");
|
const stats = deleteNodeTraces(graphStore, "n_dead");
|
||||||
assert.deepEqual(stats, { edgesRemoved: 2 } satisfies NodeDeletionStats);
|
assert.deepEqual(stats, { chainsRemoved: 2 } satisfies NodeDeletionStats);
|
||||||
|
|
||||||
// The node's EVERY line is gone; the neighbor is byte-identical
|
// The node's EVERY line is gone; the neighbor is byte-identical
|
||||||
// (full death is node-scoped — other nodes' sessions keep their
|
// (full death is node-scoped — other nodes' sessions keep their
|
||||||
@@ -824,15 +827,17 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
|||||||
keepLineBefore,
|
keepLineBefore,
|
||||||
"the untouched node is byte-identical",
|
"the untouched node is byte-identical",
|
||||||
);
|
);
|
||||||
// Only the edge to nowhere rode along; the foreign edge X→Z stays.
|
// Only the chains referencing X died; the foreign chain stays.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
graphStore.readEdges().map((e) => [e.from, e.to]),
|
graphStore.readChains().map((c) => [c.premises, c.conclusion]),
|
||||||
[["n_keep", "n_other"]],
|
[[["n_keep"], "n_other"]],
|
||||||
);
|
);
|
||||||
// The resting position died with the node.
|
// The resting position died with the node.
|
||||||
const positions = graphStore.readPositions();
|
const positions = graphStore.readPositions();
|
||||||
assert.equal(positions["n_dead"], undefined);
|
assert.equal(positions["n_dead"], undefined);
|
||||||
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 });
|
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 });
|
||||||
|
assert.equal(positions["arg_dead_keep"], undefined, "the dying chains' imp-node positions die too");
|
||||||
|
assert.equal(positions["arg_keep_dead"], undefined);
|
||||||
// Atomic rewrites left no temp files.
|
// Atomic rewrites left no temp files.
|
||||||
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
|
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
|
||||||
// Idempotent: a second run finds no node — null.
|
// Idempotent: a second run finds no node — null.
|
||||||
@@ -849,7 +854,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
|||||||
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
|
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
|
||||||
});
|
});
|
||||||
const stats = deleteNodeTraces(graphStore, "n_solo");
|
const stats = deleteNodeTraces(graphStore, "n_solo");
|
||||||
assert.deepEqual(stats, { edgesRemoved: 0 } satisfies NodeDeletionStats);
|
assert.deepEqual(stats, { chainsRemoved: 0 } satisfies NodeDeletionStats);
|
||||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
|
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -899,7 +904,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
|||||||
const revisionBefore = graphRevision();
|
const revisionBefore = graphRevision();
|
||||||
const outcome = deleteGraphNode("n_orch");
|
const outcome = deleteGraphNode("n_orch");
|
||||||
assert.ok(outcome.ok);
|
assert.ok(outcome.ok);
|
||||||
assert.deepEqual(outcome.stats, { edgesRemoved: 0 });
|
assert.deepEqual(outcome.stats, { chainsRemoved: 0 });
|
||||||
assert.ok(outcome.graphRevision > 0);
|
assert.ok(outcome.graphRevision > 0);
|
||||||
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
|
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
|
||||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
|
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts";
|
import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts";
|
||||||
import { settleGraphLayout } from "../src/core/layout-settle.ts";
|
import { settleGraphLayout } from "../src/core/layout-settle.ts";
|
||||||
import type { GraphNode, GraphEdge, Position } from "../src/core/contracts.ts";
|
import type { GraphChain, GraphNode, Position } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
const node = (id: string): GraphNode => ({
|
const node = (id: string): GraphNode => ({
|
||||||
id,
|
id,
|
||||||
@@ -18,7 +18,9 @@ const node = (id: string): GraphNode => ({
|
|||||||
createdAt: "2026-09-13T00:00:00.000Z",
|
createdAt: "2026-09-13T00:00:00.000Z",
|
||||||
provenance: [],
|
provenance: [],
|
||||||
});
|
});
|
||||||
const edge = (from: string, to: string): GraphEdge => ({ from, to, type: "supports", provenance: [] });
|
const chain = (id: string, premises: string[], conclusion: string): GraphChain => ({ id, premises, conclusion, provenance: [], createdAt: "2026-09-13T00:00:00.000Z" });
|
||||||
|
/** incrementalLayout's link shape — the DERIVED untyped arrow. */
|
||||||
|
const edge = (from: string, to: string): { from: string; to: string } => ({ from, to });
|
||||||
|
|
||||||
const mean = (positions: Map<string, Position>, ids: string[]): { x: number; y: number } => ({
|
const mean = (positions: Map<string, Position>, ids: string[]): { x: number; y: number } => ({
|
||||||
x: ids.reduce((s, id) => s + positions.get(id)!.x, 0) / ids.length,
|
x: ids.reduce((s, id) => s + positions.get(id)!.x, 0) / ids.length,
|
||||||
@@ -107,20 +109,20 @@ describe("settleGraphLayout", () => {
|
|||||||
const lines = [node("a"), node("b"), node("a")]; // "a" appended twice
|
const lines = [node("a"), node("b"), node("a")]; // "a" appended twice
|
||||||
const store = {
|
const store = {
|
||||||
readNodes: () => lines.map((l) => ({ ...l })),
|
readNodes: () => lines.map((l) => ({ ...l })),
|
||||||
readEdges: () => [edge("a", "b")],
|
readChains: () => [chain("arg_ab", ["a"], "b")],
|
||||||
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", "b"], "one position per UNIQUE node");
|
assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "arg_ab", "b"], "one position per unique node AND the derived implication node");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing on an empty graph", () => {
|
it("does nothing on an empty graph", () => {
|
||||||
const written: Record<string, Position>[] = [];
|
const written: Record<string, Position>[] = [];
|
||||||
settleGraphLayout({
|
settleGraphLayout({
|
||||||
readNodes: () => [],
|
readNodes: () => [],
|
||||||
readEdges: () => [],
|
readChains: () => [],
|
||||||
readPositions: () => ({} as Record<string, Position>),
|
readPositions: () => ({} as Record<string, Position>),
|
||||||
writePositions: (p) => void written.push(p),
|
writePositions: (p) => void written.push(p),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,18 +27,18 @@ import {
|
|||||||
type RunningPractice,
|
type RunningPractice,
|
||||||
} from "../src/agent/subagents/practice-pipeline.ts";
|
} from "../src/agent/subagents/practice-pipeline.ts";
|
||||||
import { chainCoverage } from "../src/core/practice.ts";
|
import { chainCoverage } from "../src/core/practice.ts";
|
||||||
import type { GraphEdge, GraphNode, ProblemSet } from "../src/core/contracts.ts";
|
import type { GraphChain, GraphNode, ProblemSet } from "../src/core/contracts.ts";
|
||||||
import type { ChatStore, GraphStore, PracticeStore } from "../src/core/ports.ts";
|
import type { ChatStore, GraphStore, PracticeStore } from "../src/core/ports.ts";
|
||||||
import { ModelsNotConfiguredError } from "../src/core/settings.ts";
|
import { ModelsNotConfiguredError } from "../src/core/settings.ts";
|
||||||
|
|
||||||
// ─── in-memory stores ────────────────────────────────────────────────────────
|
// ─── in-memory stores ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function memoryGraph(nodes: GraphNode[], edges: GraphEdge[]): GraphStore {
|
function memoryGraph(nodes: GraphNode[], chains: GraphChain[]): GraphStore {
|
||||||
return {
|
return {
|
||||||
appendNode: () => {},
|
appendNode: () => {},
|
||||||
readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })),
|
readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })),
|
||||||
appendEdge: () => {},
|
appendChain: () => {},
|
||||||
readEdges: () => edges.map((e) => ({ ...e, provenance: [...e.provenance] })),
|
readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
|
||||||
appendCheckpoint: () => {},
|
appendCheckpoint: () => {},
|
||||||
readCheckpoints: () => [],
|
readCheckpoints: () => [],
|
||||||
appendCoverage: () => {},
|
appendCoverage: () => {},
|
||||||
@@ -284,15 +284,16 @@ function diamondGraph(): GraphStore {
|
|||||||
createdAt: NOW,
|
createdAt: NOW,
|
||||||
provenance: pt("chat_1"),
|
provenance: pt("chat_1"),
|
||||||
});
|
});
|
||||||
const edge = (from: string, to: string, type: "follows" | "supports" = "follows"): GraphEdge => ({
|
const chain = (from: string, to: string): GraphChain => ({
|
||||||
from,
|
id: to, // fixture id = conclusion id (the form the generator prompt renders)
|
||||||
to,
|
premises: [from],
|
||||||
type,
|
conclusion: to,
|
||||||
provenance: pt("chat_1"),
|
provenance: pt("chat_1"),
|
||||||
|
createdAt: NOW,
|
||||||
});
|
});
|
||||||
return memoryGraph(
|
return memoryGraph(
|
||||||
[node("a", "A"), node("b", "B"), node("c", "C"), node("d", "D"), node("e", "E")],
|
[node("a", "A"), node("b", "B"), node("c", "C"), node("d", "D"), node("e", "E")],
|
||||||
[edge("a", "b"), edge("a", "c"), edge("b", "d"), edge("c", "d")],
|
[chain("a", "b"), chain("a", "c"), { id: "d", premises: ["b", "c"], conclusion: "d", provenance: pt("chat_1"), createdAt: NOW }],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +373,7 @@ describe("createProblemSet (generation)", () => {
|
|||||||
|
|
||||||
assert.match(set.id, /^ps_/);
|
assert.match(set.id, /^ps_/);
|
||||||
assert.equal(set.status, "partitioning");
|
assert.equal(set.status, "partitioning");
|
||||||
assert.equal(set.title, "Practice — 2026-09-29"); // nodeIds path: dated fallback title
|
assert.equal(set.title, `Practice — ${new Date().toISOString().slice(0, 10)}`); // nodeIds path: dated fallback title
|
||||||
|
|
||||||
// Stubs appear the moment the partition lands — numbered, generating.
|
// Stubs appear the moment the partition lands — numbered, generating.
|
||||||
await until(() => f.practice.store.getSet(set.id)?.status === "generating");
|
await until(() => f.practice.store.getSet(set.id)?.status === "generating");
|
||||||
|
|||||||
+64
-107
@@ -1,115 +1,107 @@
|
|||||||
/**
|
/**
|
||||||
* PRACTICE-MODULE CORE (slice 1, 2026-09-29 plan): the chain bijection —
|
* PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
|
||||||
* chainsForSelection (group by conclusion, FOLLOWS ONLY — supports are
|
* (chains-primary, owner 2026-09-30: a chain rides along iff every member
|
||||||
* invisible to practice, owner ruling) / chainsToGraph (the exact inverse,
|
* is selected; nodes in no surviving chain are singletons) and
|
||||||
* round-trip pinned) — and chainCoverage (the set's completeness law:
|
* chainCoverage (the set's completeness law: union of the problems' chains
|
||||||
* union of the problems' chains must equal the set's chains).
|
* must equal the set's chains).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type { EdgeType } from "../src/core/contracts.ts";
|
import type { GraphChain } from "../src/core/contracts.ts";
|
||||||
import { chainCoverage, chainsForSelection, chainsToEdges, chainsToGraph, formatChainLine } from "../src/core/practice.ts";
|
import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
|
||||||
|
|
||||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const n = (id: string) => ({ id });
|
const n = (id: string) => ({ id });
|
||||||
const e = (from: string, to: string, type: EdgeType = "follows") => ({ from, to, type });
|
const arg = (id: string, premises: string[], conclusion: string): GraphChain => ({
|
||||||
|
id,
|
||||||
|
premises,
|
||||||
|
conclusion,
|
||||||
|
provenance: [],
|
||||||
|
createdAt: "2026-09-30T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
/** The plan's canonical shape: diamond a→b, a→c, b→d, c→d. */
|
/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
|
||||||
const diamond = {
|
* arguments (each its own arg_ id, conclusion ids may repeat). */
|
||||||
nodes: [n("a"), n("b"), n("c"), n("d")],
|
const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
|
||||||
edges: [e("a", "b"), e("a", "c"), e("b", "d"), e("c", "d")],
|
const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
|
||||||
};
|
|
||||||
const diamondChains = [
|
|
||||||
{ id: "b", premises: ["a"], conclusion: "b" },
|
|
||||||
{ id: "c", premises: ["a"], conclusion: "c" },
|
|
||||||
{ id: "d", premises: ["b", "c"], conclusion: "d" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── chainsForSelection ──────────────────────────────────────────────────────
|
// ─── chainsForSelection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("chainsForSelection", () => {
|
describe("chainsForSelection", () => {
|
||||||
it("groups by conclusion: the diamond a→b, a→c, b→d, c→d is three chains", () => {
|
it("keeps every fully-selected stored argument, each under its own id", () => {
|
||||||
assert.deepEqual(chainsForSelection(diamond.nodes, diamond.edges), diamondChains);
|
assert.deepEqual(chainsForSelection(diamondNodes, diamondArgs), [
|
||||||
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||||
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||||
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a premise-only node is NOT its own chain — it lives inside the chains it feeds", () => {
|
it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
|
||||||
const ids = chainsForSelection(diamond.nodes, diamond.edges).map((c) => c.id);
|
const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
|
||||||
assert.ok(!ids.includes("a")); // a is only ever a premise
|
assert.ok(!ids.includes("a")); // a is only ever a premise
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an isolated node becomes a singleton chain", () => {
|
it("an isolated node becomes a singleton chain", () => {
|
||||||
const chains = chainsForSelection([...diamond.nodes, n("z")], diamond.edges);
|
const chains = chainsForSelection([...diamondNodes, n("z")], diamondArgs);
|
||||||
assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
|
assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports edges are NO edges — nodes joined only by supports are singletons", () => {
|
it("two independent arguments for one conclusion stay DISTINCT chains", () => {
|
||||||
const chains = chainsForSelection([n("x"), n("y")], [e("x", "y", "supports")]);
|
// The bijection problem that killed edge-grouping (owner, 2026-09-30):
|
||||||
|
// A∧B⇒E and C∧D⇒E must never union into A∧B∧C∧D⇒E.
|
||||||
|
const chains = chainsForSelection(
|
||||||
|
[n("a"), n("b"), n("c"), n("d"), n("e")],
|
||||||
|
[arg("arg_e1", ["a", "b"], "e"), arg("arg_e2", ["c", "d"], "e")],
|
||||||
|
);
|
||||||
assert.deepEqual(chains, [
|
assert.deepEqual(chains, [
|
||||||
{ id: "x", premises: [], conclusion: "x" },
|
{ id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
|
||||||
{ id: "y", premises: [], conclusion: "y" },
|
{ id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an edge with an endpoint outside the selection is excluded entirely", () => {
|
it("an argument with ANY member outside the selection is excluded entirely", () => {
|
||||||
// a→out would chain a; out is not selected, so the edge is gone and
|
// arg_ab needs b; b is not selected, so the argument is gone and
|
||||||
// a (nothing else left) falls back to a singleton.
|
// a (nothing else left) falls back to a singleton.
|
||||||
const chains = chainsForSelection([n("a")], [e("a", "out"), e("in", "a")]);
|
const chains = chainsForSelection([n("a")], [arg("arg_ab", ["a"], "b"), arg("arg_ia", ["in"], "a")]);
|
||||||
assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
|
assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("duplicate edge assertions collapse into one premise", () => {
|
it("a chain referencing unknown node ids simply never applies", () => {
|
||||||
const chains = chainsForSelection([n("a"), n("b")], [e("a", "b"), e("a", "b")]);
|
const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]);
|
||||||
assert.deepEqual(chains, [{ id: "b", premises: ["a"], conclusion: "b" }]);
|
assert.deepEqual(chains, [
|
||||||
|
{ id: "a", premises: [], conclusion: "a" },
|
||||||
|
{ id: "b", premises: [], conclusion: "b" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is deterministic — chains and premises sorted by id", () => {
|
it("is deterministic — chains sorted by id, premises sorted", () => {
|
||||||
const shuffled = chainsForSelection(
|
const shuffled = chainsForSelection(
|
||||||
[n("d"), n("c"), n("b"), n("a")],
|
[n("d"), n("c"), n("b"), n("a")],
|
||||||
[e("c", "d"), e("a", "c"), e("a", "b"), e("b", "d")],
|
[arg("arg_d", ["c", "b"], "d"), arg("arg_c", ["a"], "c"), arg("arg_b", ["a"], "b")],
|
||||||
);
|
);
|
||||||
assert.deepEqual(shuffled, diamondChains);
|
assert.deepEqual(shuffled, [
|
||||||
});
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||||
});
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||||
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||||
// ─── chainsToGraph — the bijection ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("chainsToGraph (the inverse)", () => {
|
|
||||||
it("reproduces exactly the selection's follows-edges (both ends selected)", () => {
|
|
||||||
const supportsNoise = e("x", "y", "supports");
|
|
||||||
const outside = e("a", "out");
|
|
||||||
const { edges } = chainsToGraph(chainsForSelection(diamond.nodes, [...diamond.edges, supportsNoise, outside]));
|
|
||||||
assert.deepEqual(edges, [...diamond.edges]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips a canonical chain list — singletons survive only for edge-less nodes", () => {
|
|
||||||
// NOTE: the bijection is over chainsForSelection's IMAGE — lists where a
|
|
||||||
// singleton's node never also appears as a premise/conclusion elsewhere.
|
|
||||||
// (A "singleton + premise elsewhere" list is degenerate: not something
|
|
||||||
// the translation ever produces.)
|
|
||||||
const canonical = [
|
|
||||||
{ id: "p1", premises: ["a", "b"], conclusion: "p1" },
|
|
||||||
{ id: "p9", premises: [], conclusion: "p9" },
|
|
||||||
{ id: "c1", premises: ["p1"], conclusion: "c1" },
|
|
||||||
];
|
|
||||||
const nodeIds = [...new Set(canonical.flatMap((c) => [c.conclusion, ...c.premises]))];
|
|
||||||
const roundTripped = chainsForSelection(nodeIds.map(n), chainsToGraph(canonical).edges);
|
|
||||||
assert.deepEqual(roundTripped, [
|
|
||||||
{ id: "c1", premises: ["p1"], conclusion: "c1" },
|
|
||||||
{ id: "p1", premises: ["a", "b"], conclusion: "p1" },
|
|
||||||
{ id: "p9", premises: [], conclusion: "p9" },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── chainCoverage ───────────────────────────────────────────────────────────
|
// ─── chainCoverage ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const diamondChains = [
|
||||||
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||||
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||||
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||||
|
];
|
||||||
|
|
||||||
describe("chainCoverage", () => {
|
describe("chainCoverage", () => {
|
||||||
it("complete cover: every chain exercised, no missing", () => {
|
it("complete cover: every chain exercised, no missing", () => {
|
||||||
const set = {
|
const set = {
|
||||||
chains: diamondChains,
|
chains: diamondChains,
|
||||||
problems: [{ chains: ["b", "c"] }, { chains: ["d"] }],
|
problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
|
||||||
};
|
};
|
||||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
||||||
});
|
});
|
||||||
@@ -117,23 +109,23 @@ describe("chainCoverage", () => {
|
|||||||
it("missing: a chain no problem exercises", () => {
|
it("missing: a chain no problem exercises", () => {
|
||||||
const set = {
|
const set = {
|
||||||
chains: diamondChains,
|
chains: diamondChains,
|
||||||
problems: [{ chains: ["d"] }],
|
problems: [{ chains: ["arg_d"] }],
|
||||||
};
|
};
|
||||||
assert.deepEqual(chainCoverage(set), { missing: ["b", "c"], duplicated: [] });
|
assert.deepEqual(chainCoverage(set), { missing: ["arg_b", "arg_c"], duplicated: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("duplicated is informational — a chain exercised twice is allowed", () => {
|
it("duplicated is informational — a chain exercised twice is allowed", () => {
|
||||||
const set = {
|
const set = {
|
||||||
chains: diamondChains,
|
chains: diamondChains,
|
||||||
problems: [{ chains: ["b", "c", "d"] }, { chains: ["b", "d"] }],
|
problems: [{ chains: ["arg_b", "arg_c", "arg_d"] }, { chains: ["arg_b", "arg_d"] }],
|
||||||
};
|
};
|
||||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["b", "d"] });
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["arg_b", "arg_d"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
|
it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
|
||||||
const set = {
|
const set = {
|
||||||
chains: [{ id: "b" }] as Array<{ id: string }>,
|
chains: [{ id: "arg_b" }] as Array<{ id: string }>,
|
||||||
problems: [{ chains: ["b", "ghost"] }],
|
problems: [{ chains: ["arg_b", "ghost"] }],
|
||||||
};
|
};
|
||||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
||||||
});
|
});
|
||||||
@@ -159,38 +151,3 @@ describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () =>
|
|||||||
assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
|
assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── chainsToEdges — the extraction seam's expansion (2026-09-30) ────────────
|
|
||||||
|
|
||||||
describe("chainsToEdges (the extractor redesign's translation seam)", () => {
|
|
||||||
it("each premise becomes ONE follows-edge into the conclusion", () => {
|
|
||||||
const edges = chainsToEdges([{ premises: ["p1", "p2"], conclusion: "c1" }]);
|
|
||||||
assert.deepEqual(edges, [
|
|
||||||
{ from: "p1", to: "c1", type: "follows" },
|
|
||||||
{ from: "p2", to: "c1", type: "follows" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("chains sharing a conclusion union their premises; the result stays sorted", () => {
|
|
||||||
const edges = chainsToEdges([
|
|
||||||
{ premises: ["p2"], conclusion: "c1" },
|
|
||||||
{ premises: ["p1"], conclusion: "c1" },
|
|
||||||
]);
|
|
||||||
assert.deepEqual(edges, [
|
|
||||||
{ from: "p1", to: "c1", type: "follows" },
|
|
||||||
{ from: "p2", to: "c1", type: "follows" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("identical (premise-set, conclusion) chains dedupe silently — restating is a no-op", () => {
|
|
||||||
const chain = { premises: ["p1", "p2"], conclusion: "c1" };
|
|
||||||
assert.deepEqual(chainsToEdges([chain, chain, { premises: ["p2", "p1"], conclusion: "c1" }]), [
|
|
||||||
{ from: "p1", to: "c1", type: "follows" },
|
|
||||||
{ from: "p2", to: "c1", type: "follows" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty in, empty out — bare propositions ride the propositions list alone", () => {
|
|
||||||
assert.deepEqual(chainsToEdges([]), []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { ChainTableBody } from "../components/chain-table.tsx";
|
import { ChainTableBody } from "../components/chain-table.tsx";
|
||||||
import type { ChainSort } from "../components/chain-table.tsx";
|
import type { ChainSort } from "../components/chain-table.tsx";
|
||||||
import type { GraphEdge, GraphNode } from "../src/core/contracts.ts";
|
import type { GraphChain, GraphNode } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
/** The fixture graph — two follows-chains + one standalone:
|
/** The fixture graph — two follows-chains + one standalone:
|
||||||
* a → c1 ← b (chain one, conclusion born chat1), d → e (chain two,
|
* a → c1 ← b (chain one, conclusion born chat1), d → e (chain two,
|
||||||
@@ -58,15 +58,14 @@ const NODES: GraphNode[] = [
|
|||||||
node("orphan", "Orphan", "Statement O.", null),
|
node("orphan", "Orphan", "Statement O.", null),
|
||||||
];
|
];
|
||||||
|
|
||||||
const EDGES: GraphEdge[] = [
|
const CHAINS: GraphChain[] = [
|
||||||
{ from: "a", to: "c1", type: "follows" },
|
{ id: "c1", premises: ["a", "b"], conclusion: "c1", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
|
||||||
{ from: "b", to: "c1", type: "follows" },
|
{ id: "e", premises: ["d"], conclusion: "e", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
|
||||||
{ from: "d", to: "e", type: "follows" },
|
];
|
||||||
] as GraphEdge[];
|
|
||||||
|
|
||||||
const BASE = {
|
const BASE = {
|
||||||
nodes: NODES,
|
nodes: NODES,
|
||||||
edges: EDGES,
|
chains: CHAINS,
|
||||||
sessions: { chat1: "Ethics lesson", chat2: "Second chat" },
|
sessions: { chat1: "Ethics lesson", chat2: "Second chat" },
|
||||||
fileSources: { notes1: "notes/raw/ethics.md" },
|
fileSources: { notes1: "notes/raw/ethics.md" },
|
||||||
query: "",
|
query: "",
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
arrowLength: 3,
|
arrowLength: 3,
|
||||||
labelZoom: 2,
|
labelZoom: 2,
|
||||||
labelSize: 4,
|
labelSize: 4,
|
||||||
showSupports: false,
|
|
||||||
supportsWidth: 0.3,
|
|
||||||
clusterLabels: false,
|
clusterLabels: false,
|
||||||
clusterLabelSize: 20,
|
clusterLabelSize: 20,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
@@ -128,27 +126,13 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member
|
assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member
|
||||||
});
|
});
|
||||||
|
|
||||||
test("showSupports (2026-09-29): boolean-only, default on — the draw toggle", () => {
|
test("supports settings are GONE (chains-primary, 2026-09-30): stale keys silently drop", () => {
|
||||||
// A render-only hide for supports edges: anything but a real boolean
|
// Supports edges were retired; showSupports/supportsWidth no longer
|
||||||
// (missing key, legacy file, garbage) loads as ON (today's look); a
|
// exist. A legacy file carrying them loads without the keys — the
|
||||||
// real false round-trips untouched.
|
// sanitizer drops unknown fields by construction.
|
||||||
assert.equal(sanitizeRenderSettings({}).showSupports, true);
|
const cleaned = sanitizeRenderSettings({ showSupports: false, supportsWidth: 0.9 } as never);
|
||||||
assert.equal(sanitizeRenderSettings({ showSupports: false }).showSupports, false);
|
assert.equal("showSupports" in cleaned, false);
|
||||||
assert.equal(sanitizeRenderSettings({ showSupports: "no" }).showSupports, true);
|
assert.equal("supportsWidth" in cleaned, false);
|
||||||
assert.equal(sanitizeRenderSettings({ showSupports: 0 }).showSupports, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("supportsWidth (2026-09-29): linear 0–1 ratio of edgeWidth, default 0.5", () => {
|
|
||||||
// The supports shaft-thickness multiplier: LINEAR (not a ladder —
|
|
||||||
// it's a ratio), real numbers clamp to [0, 1], anything else resets
|
|
||||||
// to the default. The 0 end floors to a hairline in the renderer.
|
|
||||||
assert.equal(sanitizeRenderSettings({}).supportsWidth, 0.5);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: 0 }).supportsWidth, 0);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: 1 }).supportsWidth, 1);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: 0.37 }).supportsWidth, 0.37);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: 1.7 }).supportsWidth, 1);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: -0.3 }).supportsWidth, 0);
|
|
||||||
assert.equal(sanitizeRenderSettings({ supportsWidth: "thick" }).supportsWidth, 0.5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => {
|
test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type { GraphEdge, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts";
|
import type { GraphChain, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts";
|
||||||
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
|
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
|
||||||
import {
|
import {
|
||||||
graphSessionIndex,
|
graphSessionIndex,
|
||||||
@@ -40,10 +40,10 @@ function fakeStores(
|
|||||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||||
} = {},
|
} = {},
|
||||||
): GraphDeps {
|
): GraphDeps {
|
||||||
const edges: GraphEdge[] = [];
|
const chains: GraphChain[] = [];
|
||||||
const graphStore: GraphStore = {
|
const graphStore: GraphStore = {
|
||||||
readNodes: () => nodes,
|
readNodes: () => nodes,
|
||||||
readEdges: () => edges,
|
readChains: () => chains,
|
||||||
readCheckpoints: () => [],
|
readCheckpoints: () => [],
|
||||||
readCoverage: () => [],
|
readCoverage: () => [],
|
||||||
readSources: () =>
|
readSources: () =>
|
||||||
|
|||||||
+2
-2
@@ -244,7 +244,7 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl
|
|||||||
startedAt,
|
startedAt,
|
||||||
at: "2026-09-09T00:00:00Z",
|
at: "2026-09-09T00:00:00Z",
|
||||||
durationMs: 42_000,
|
durationMs: 42_000,
|
||||||
stats: { nodes: 1, edges: 2 },
|
stats: { nodes: 1, chains: 2 },
|
||||||
stages: {
|
stages: {
|
||||||
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
||||||
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
||||||
@@ -281,7 +281,7 @@ test("appendSourceUsage: appends to the source record, idempotent per startedAt,
|
|||||||
startedAt,
|
startedAt,
|
||||||
at: "2026-09-13T00:00:00Z",
|
at: "2026-09-13T00:00:00Z",
|
||||||
durationMs: 42_000,
|
durationMs: 42_000,
|
||||||
stats: { nodes: 1, edges: 2 },
|
stats: { nodes: 1, chains: 2 },
|
||||||
stages: {
|
stages: {
|
||||||
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
|
||||||
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
|
||||||
|
|||||||
Reference in New Issue
Block a user