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:
+61
-28
@@ -18,20 +18,51 @@
|
||||
*/
|
||||
// ─── 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";
|
||||
|
||||
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
|
||||
* 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 {
|
||||
/** New graph nodes appended. */
|
||||
nodes: number;
|
||||
/** New edge assertions appended. */
|
||||
edges: number;
|
||||
/** New reasoning chains appended. */
|
||||
chains?: number;
|
||||
}
|
||||
|
||||
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
|
||||
@@ -107,14 +138,6 @@ export interface GraphNode {
|
||||
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
|
||||
* have been folded, and through which updatedAt — never fold twice). */
|
||||
export interface SessionCheckpoint {
|
||||
@@ -143,7 +166,9 @@ export interface CoverageEntry {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* name/statement/createdAt; later lines for the
|
||||
@@ -151,8 +176,9 @@ export interface CoverageEntry {
|
||||
* their statements/aliases join `aliases`, their
|
||||
* provenance unions; the name stays the first line's.
|
||||
* getGraph merges by id.
|
||||
* edges.jsonl one line per edge ASSERTION; readers merge by
|
||||
* (from,to,type), unioning provenance.
|
||||
* chains.jsonl one line per chain ASSERTION; readers merge by
|
||||
* (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,
|
||||
* derive state. sessions.jsonl is the extraction checkpoint: which chats
|
||||
@@ -202,21 +228,20 @@ export interface ExtractedProposition {
|
||||
* + structure, nothing else. */
|
||||
}
|
||||
|
||||
/** One proposed relation. Endpoints are extractor-local proposition ids
|
||||
* ("p1", …) — the extractor is the ONLY stage that ever proposes edges
|
||||
* (the auditor's edges argument is gone, owner 2026-09-28 evening). */
|
||||
export interface ExtractedEdge {
|
||||
/** Proposition id that is followed / supported. */
|
||||
from: string;
|
||||
/** Proposition id that follows / is supported. */
|
||||
to: string;
|
||||
type: EdgeType;
|
||||
/** One proposed argument (extractor redesign, 2026-09-30). Endpoints are
|
||||
* extractor-local proposition ids ("p1", …) — the extractor is the ONLY
|
||||
* stage that ever proposes chains; the merger resolves them to node ids. */
|
||||
export interface ExtractedChain {
|
||||
/** Proposition ids that JOINTLY entail the conclusion. */
|
||||
premises: string[];
|
||||
/** The proposition id the premises jointly imply. */
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
/** The extractor's output shape: the raw reasoning chains. */
|
||||
export interface SessionFindings {
|
||||
propositions: ExtractedProposition[];
|
||||
edges: ExtractedEdge[];
|
||||
chains: ExtractedChain[];
|
||||
}
|
||||
|
||||
/** One identity verdict: the extractor's local proposition id → the
|
||||
@@ -279,8 +304,16 @@ export interface GraphClusterGroup {
|
||||
}
|
||||
|
||||
export interface GraphSnapshot {
|
||||
/** Proposition nodes + the DERIVED implication nodes (one per chain,
|
||||
* id = chain id) — what the canvas renders. */
|
||||
nodes: GraphNode[];
|
||||
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
|
||||
* of the stored chains — rebuilt after every change, never stored. */
|
||||
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. */
|
||||
sessions: Record<string, string>;
|
||||
/** 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,
|
||||
* 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):
|
||||
*
|
||||
* preserve-graph the chat's extraction bookkeeping dies — its coverage
|
||||
* 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
|
||||
* snapshot simply no longer resolves the dead chatId to
|
||||
* a title (nodeSessions filters unknown ids at read
|
||||
@@ -21,17 +27,16 @@
|
||||
*
|
||||
* delete-nodes "this session never happened": every trace of the
|
||||
* 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
|
||||
* rows — and orphans die: a node
|
||||
* whose references all came from this chat disappears
|
||||
* entirely, pruned from the positions and embedding
|
||||
* ledgers too (dead members would break cluster hulls).
|
||||
* entirely, pruned from the positions ledger too (dead
|
||||
* members would break cluster hulls).
|
||||
*
|
||||
* Shared nodes survive automatically (the ruling): a node lives iff ANY
|
||||
* surviving reference remains — a provenance point from another chat or
|
||||
* notes source, or a surviving event (another session's quiz outcome /
|
||||
* observation) naming it. The create line of a surviving node is special:
|
||||
* notes source. The create line of a surviving node is special:
|
||||
* name/statement/createdAt are the node's IDENTITY — the shared anchor
|
||||
* other sessions' link verdicts recognized — so they stay even when the
|
||||
* deleted chat created the node; but that session's phrasings (the line's
|
||||
@@ -39,9 +44,6 @@
|
||||
* 2026-09-23: all traces, identity survives).
|
||||
*
|
||||
* 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
|
||||
* own lifecycle (re-import dedupes by path hash); this wave deletes
|
||||
* CHATS.
|
||||
@@ -55,7 +57,7 @@
|
||||
|
||||
import type {
|
||||
CoverageEntry,
|
||||
GraphEdge,
|
||||
GraphChain,
|
||||
GraphNode,
|
||||
SessionCheckpoint,
|
||||
} from "./contracts.ts";
|
||||
@@ -71,8 +73,8 @@ export type ChatDeleteMode = "preserve-graph" | "delete-nodes";
|
||||
export interface ChatDeletionStats {
|
||||
/** Distinct nodes that died entirely (all their lines). */
|
||||
nodesRemoved: number;
|
||||
/** Edge assertion LINES removed (empty-provenance or dead endpoint). */
|
||||
edgesRemoved: number;
|
||||
/** Chain assertion LINES removed (empty-provenance or dead member). */
|
||||
chainsRemoved: number;
|
||||
/** Coverage rows removed. */
|
||||
coverageRemoved: number;
|
||||
/** Checkpoint lines removed. */
|
||||
@@ -81,7 +83,7 @@ export interface ChatDeletionStats {
|
||||
|
||||
const EMPTY_STATS: ChatDeletionStats = {
|
||||
nodesRemoved: 0,
|
||||
edgesRemoved: 0,
|
||||
chainsRemoved: 0,
|
||||
coverageRemoved: 0,
|
||||
checkpointsRemoved: 0,
|
||||
};
|
||||
@@ -116,7 +118,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
||||
|
||||
// ── delete-nodes: the full purge ──────────────────────────────────────
|
||||
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
|
||||
// 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.)
|
||||
const touched =
|
||||
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 (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) {
|
||||
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
|
||||
// remains — another chat/src's provenance point on any of its lines,
|
||||
// a surviving mapping line (notes-front-door nodes carry EMPTY
|
||||
// provenance: the file IS the anchor, recorded through the mapping
|
||||
// line). (Edge endpoints are deliberately
|
||||
// NOT survival references: an edge's asserting session always left
|
||||
// provenance on its endpoints, so endpoint-only liveness cannot
|
||||
// occur in a consistent ledger — and counting it would keep nodes
|
||||
// alive whose lines we would then drop, dangling the edge.)
|
||||
// remains — another chat/src's provenance point on any of its lines.
|
||||
// (Chain members are deliberately NOT survival references: a
|
||||
// chain's asserting session always left provenance on its endpoints,
|
||||
// so endpoint-only liveness cannot occur in a consistent ledger —
|
||||
// and counting it would keep nodes alive whose lines we would then
|
||||
// drop, dangling the chain.)
|
||||
const survives = (points: ReadonlyArray<{ chatId: string }>): boolean =>
|
||||
points.some((pt) => pt.chatId !== chatId);
|
||||
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.
|
||||
}
|
||||
|
||||
// 3. Edges: strip the chat's points; an assertion dies when its
|
||||
// provenance empties or either endpoint died IN THIS DELETION.
|
||||
// (Only nodes that existed and died count — an edge whose endpoints
|
||||
// never resolved as nodes is foreign data the deletion leaves alone:
|
||||
// destructive rewriting may not exceed the deleted chat's blast
|
||||
// radius.)
|
||||
// 3. Chains: strip the chat's points; an assertion dies when its
|
||||
// provenance empties or ANY member node (premise or conclusion)
|
||||
// died IN THIS DELETION. (Only nodes that existed and died count —
|
||||
// a chain whose members never resolved as nodes is foreign data the
|
||||
// deletion leaves alone: destructive rewriting may not exceed the
|
||||
// deleted chat's blast radius.)
|
||||
const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)));
|
||||
const outEdges: GraphEdge[] = [];
|
||||
for (const line of edgeLines) {
|
||||
if (deadNodes.has(line.from) || deadNodes.has(line.to)) continue;
|
||||
const outChains: GraphChain[] = [];
|
||||
for (const line of chainLines) {
|
||||
if (deadNodes.has(line.conclusion) || line.premises.some((premise) => deadNodes.has(premise))) continue;
|
||||
const points = line.provenance.filter((pt) => pt.chatId !== chatId);
|
||||
// An assertion dies when THIS deletion empties its provenance — a
|
||||
// line that already had no points is foreign/legacy data, not this
|
||||
// chat's trace; it rides untouched.
|
||||
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.edgesRemoved = edgeLines.length - outEdges.length;
|
||||
stats.chainsRemoved = chainLines.length - outChains.length;
|
||||
|
||||
graphStore.rewriteLedgers({
|
||||
nodes: outNodes,
|
||||
edges: outEdges,
|
||||
chains: outChains,
|
||||
coverage,
|
||||
checkpoints,
|
||||
});
|
||||
|
||||
// 5. Positions: prune dead nodes (stale resting spots would pin
|
||||
// nothing — but pruning keeps the ledger honest and the revision
|
||||
// moving for exactly the right reason).
|
||||
// 4. Positions: prune dead nodes AND dead chains (an argument's derived
|
||||
// implication node carries the chain's arg_ id — stale resting spots
|
||||
// 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 knownNodeIds = new Set(nodeLines.map((line) => line.id));
|
||||
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) {
|
||||
graphStore.writePositions(prunedPositions);
|
||||
@@ -224,6 +232,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ─── 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 —
|
||||
*
|
||||
* - every line of the node (create + link lines)
|
||||
* - every EDGE touching it (from or to), whatever session asserted it
|
||||
* - every mapping line that anchored it (the audit trail; for a
|
||||
* notes-born node the file IS the anchor — its mapping line is the
|
||||
* only one)
|
||||
* - its resting position
|
||||
* - every CHAIN referencing it (as premise or conclusion), whatever
|
||||
* session asserted it — a chain with a dead member is not a chain
|
||||
* - its resting position (and every dead chain's)
|
||||
*
|
||||
* 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
|
||||
* NODE-scoped, not session-scoped), and the source records of notes
|
||||
* 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.
|
||||
*/
|
||||
export interface NodeDeletionStats {
|
||||
/** Edge assertion LINES removed (every edge touching the node). */
|
||||
edgesRemoved: number;
|
||||
/** Chain assertion LINES removed (every chain referencing the node). */
|
||||
chainsRemoved: number;
|
||||
}
|
||||
|
||||
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
|
||||
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
|
||||
* orchestration maps that to 404); rewrites only the ledgers that
|
||||
* changed (unchanged files keep their mtimes — the version stamp moves
|
||||
* only when something really did).
|
||||
* Full-death one node (or one argument, by its arg_ id). Returns null
|
||||
* when no such node/chain exists (the orchestration maps that to 404);
|
||||
* rewrites only the ledgers that changed (unchanged files keep their
|
||||
* mtimes — the version stamp moves only when something really did).
|
||||
*/
|
||||
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);
|
||||
if (nodeLines.length === 0) return null;
|
||||
|
||||
const edgeLines = graphStore.readEdges();
|
||||
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId);
|
||||
const outChains = chainLines.filter(
|
||||
(line) => line.conclusion !== nodeId && !line.premises.includes(nodeId),
|
||||
);
|
||||
|
||||
// Only the touched ledgers: the node's own lines always die (its file
|
||||
// must move), everything else rewrites only when rows actually drop.
|
||||
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
|
||||
graphStore.rewriteLedgers({
|
||||
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();
|
||||
if (positions[nodeId] !== undefined) {
|
||||
const { [nodeId]: _dead, ...rest } = positions;
|
||||
graphStore.writePositions(rest);
|
||||
const prunedPositions = Object.fromEntries(
|
||||
Object.entries(positions).filter(([id]) => id !== nodeId && !deadChainIds.has(id)),
|
||||
);
|
||||
if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
|
||||
graphStore.writePositions(prunedPositions);
|
||||
}
|
||||
|
||||
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 {
|
||||
AuditVerdicts,
|
||||
ExtractedEdge,
|
||||
ExtractedChain,
|
||||
GraphChain,
|
||||
SessionFindings,
|
||||
Cluster,
|
||||
EmissionStats,
|
||||
@@ -56,6 +57,7 @@ import type {
|
||||
} from "./contracts.ts";
|
||||
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
||||
import { formatChainLine } from "./practice.ts";
|
||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||
|
||||
/** The storage ports every graph operation needs, passed explicitly. */
|
||||
@@ -69,6 +71,18 @@ function newGraphNodeId(): string {
|
||||
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
|
||||
* 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
|
||||
@@ -127,7 +141,7 @@ export function mergeFindings(
|
||||
source: MergeSource,
|
||||
): EmissionStats {
|
||||
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
|
||||
// in-memory as the propositions resolve (owner, 2026-09-29: the
|
||||
@@ -180,31 +194,41 @@ export function mergeFindings(
|
||||
mapping.set(p.id, graphNodeId);
|
||||
}
|
||||
|
||||
// 2. Edges — the extractor's own, the ONLY edge source (the auditor's
|
||||
// edges argument is gone, owner 2026-09-28 evening). Endpoints are
|
||||
// this job's local proposition ids and resolve through the job-local
|
||||
// map; edges carry the run's chat-level provenance point and dedupe
|
||||
// by (from,to,type) already asserted by THIS chat — an assertion by
|
||||
// another chat stays untouched (provenance unions at read time).
|
||||
// 2. Chains — the extractor's own, the ONLY chain source; the stored
|
||||
// truth (chains-primary, owner 2026-09-30). Endpoints are this
|
||||
// job's local proposition ids and resolve through the job-local
|
||||
// map; chains carry the run's chat-level provenance point and
|
||||
// dedupe by (premises, conclusion) already asserted by THIS chat —
|
||||
// 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>();
|
||||
for (const line of ledgers.readEdges()) {
|
||||
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
||||
const idOfKey = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
const emitEdge = (edge: ExtractedEdge): 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 emitChain = (chain: ExtractedChain): void => {
|
||||
const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
|
||||
const from = resolve(edge.from);
|
||||
const to = resolve(edge.to);
|
||||
if (!from || !to || from === to) return; // unresolvable endpoint — skip
|
||||
const key = `${from}\u0000${to}\u0000${edge.type}`;
|
||||
if (assertedByChat.has(key)) return;
|
||||
ledgers.appendEdge({ from, to, type: edge.type, provenance: pointOf(chatId) });
|
||||
const conclusion = resolve(chain.conclusion);
|
||||
if (conclusion === undefined) return; // unresolvable endpoint — skip
|
||||
// Duplicate local ids resolving to one node (two phrasings of one
|
||||
// premise) collapse; a premise equal to the conclusion is dropped.
|
||||
const premises = [...new Set(chain.premises.map(resolve))].filter(
|
||||
(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);
|
||||
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):
|
||||
// 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) ─────────────
|
||||
|
||||
/** 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 {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
chains: GraphChain[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
||||
* appends): the FIRST line defines name/statement/createdAt; later
|
||||
* lines' statements and aliases join `aliases`, provenance unions — the
|
||||
* name never changes after creation. Edge
|
||||
* LINES are assertions, merged by (from,to,type) with unioned provenance,
|
||||
* first-seen order. `store events, derive state` — this is the only reader
|
||||
* that matters.
|
||||
* name never changes after creation. Chain LINES are assertions, merged
|
||||
* by (premises, conclusion) with unioned provenance; the first line's
|
||||
* arg_ id / premises / createdAt are the chain's identity.
|
||||
* `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).
|
||||
const byId = new Map<string, GraphNode>();
|
||||
for (const line of graphStore.readNodes()) {
|
||||
@@ -254,29 +283,68 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
|
||||
}
|
||||
}
|
||||
|
||||
const merged: GraphEdge[] = [];
|
||||
const byKey = new Map<string, GraphEdge>();
|
||||
for (const line of graphStore.readEdges()) {
|
||||
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
||||
// Merge chain assertion lines by (premises, conclusion): the first
|
||||
// line's id/premises/createdAt stand, provenance unions.
|
||||
const chains: GraphChain[] = [];
|
||||
const byKey = new Map<string, GraphChain>();
|
||||
for (const line of graphStore.readChains()) {
|
||||
const key = chainKey(line.premises, line.conclusion);
|
||||
const existing = byKey.get(key);
|
||||
if (existing) {
|
||||
for (const pt of line.provenance) {
|
||||
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
|
||||
}
|
||||
} else {
|
||||
const edge: GraphEdge = { ...line, provenance: [...line.provenance] };
|
||||
byKey.set(key, edge);
|
||||
merged.push(edge);
|
||||
const chain: GraphChain = { ...line, premises: [...line.premises], provenance: [...line.provenance] };
|
||||
byKey.set(key, chain);
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
||||
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 touch = (nodeId: string, chatId: string): void => {
|
||||
@@ -290,13 +358,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
for (const node of nodes) {
|
||||
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
|
||||
// (title, deep-linkable) and imported notes files (path, static — the
|
||||
// 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
|
||||
// birth session whose record is gone (deleted chat) keeps its members
|
||||
// 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
|
||||
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
|
||||
.map((community) => ({
|
||||
@@ -346,7 +409,8 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
edges: view.edges,
|
||||
chains: graph.chains,
|
||||
sessions,
|
||||
fileSources,
|
||||
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 { birthSessionPartition } from "./session-clusters.ts";
|
||||
import { deriveView, getGraph } from "./graph.ts";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* browser barrel, and the settle needs the cluster partition for the
|
||||
* 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: {
|
||||
readNodes(): GraphNode[];
|
||||
readEdges(): GraphEdge[];
|
||||
readChains(): GraphChain[];
|
||||
readPositions(): Record<string, Position>;
|
||||
writePositions(positions: Record<string, Position>): void;
|
||||
}): void {
|
||||
const nodes = graphStore.readNodes();
|
||||
if (nodes.length === 0) return;
|
||||
// Node LINES may repeat an id (link-appends) — dedupe, first defines.
|
||||
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 graph = getGraph(graphStore);
|
||||
if (graph.nodes.length === 0) return;
|
||||
const view = deriveView(graph);
|
||||
const previous = new Map(Object.entries(graphStore.readPositions()));
|
||||
// The cluster partition for the centroid pull: the SAME birth-session
|
||||
// partition the snapshot renders (derived from provenance on read —
|
||||
// core/session-clusters.ts, wave 7) — one cluster concept everywhere.
|
||||
// The unique list is the deduped set the partition needs (lines may
|
||||
// repeat an id — first defines).
|
||||
const communities = birthSessionPartition(unique);
|
||||
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
|
||||
// PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never
|
||||
// partition; the stored world clusters, its image follows.
|
||||
const communities = birthSessionPartition(graph.nodes);
|
||||
const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities });
|
||||
graphStore.writePositions(Object.fromEntries(next));
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -14,7 +14,7 @@ import type {
|
||||
ChatRecord,
|
||||
ChatSettings,
|
||||
ChatSummary,
|
||||
GraphEdge,
|
||||
GraphChain,
|
||||
GraphNode,
|
||||
JobKind,
|
||||
JobRecord,
|
||||
@@ -65,11 +65,12 @@ export interface ChatStore {
|
||||
}
|
||||
|
||||
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
||||
// v1 surface = the five JSONL ledgers (nodes / edges / events / mappings /
|
||||
// sessions). Reads return the raw append-order lines; merging edge assertions
|
||||
// by (from, to, type) is DERIVED in core/graph.ts — the store never decides.
|
||||
// The extraction pipeline (next stage) becomes the sole writer; this read
|
||||
// surface stays.
|
||||
// Chains are the stored truth (owner, 2026-09-30): nodes + reasoning
|
||||
// chains; edges are a DERIVED view (core/graph.ts) and never touch this
|
||||
// seam. Reads return the raw append-order lines; merging chain assertions
|
||||
// by (premises, conclusion) is DERIVED in core/graph.ts — the store never
|
||||
// decides. The extraction pipeline is the sole writer; this read surface
|
||||
// stays.
|
||||
|
||||
export interface GraphStore {
|
||||
/** Append one GraphNode (ids are unique — no merging needed). */
|
||||
@@ -77,12 +78,10 @@ export interface GraphStore {
|
||||
/** Raw node lines in append order. */
|
||||
readNodes(): GraphNode[];
|
||||
|
||||
/** Append one edge ASSERTION (a second session asserting the same
|
||||
* (from, to, type) appends another line; readers merge). */
|
||||
appendEdge(assertion: GraphEdge): void;
|
||||
readEdges(): GraphEdge[];
|
||||
|
||||
/** Append one quiz outcome / understanding observation. */
|
||||
/** Append one chain ASSERTION (a second session asserting the same
|
||||
* (premises, conclusion) appends another line; readers merge). */
|
||||
appendChain(assertion: GraphChain): void;
|
||||
readChains(): GraphChain[];
|
||||
|
||||
/** Append one emission checkpoint. */
|
||||
appendCheckpoint(checkpoint: SessionCheckpoint): void;
|
||||
@@ -122,7 +121,7 @@ export interface GraphStore {
|
||||
* replacement atomic. */
|
||||
rewriteLedgers(next: {
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
chains?: GraphChain[];
|
||||
coverage?: CoverageEntry[];
|
||||
checkpoints?: SessionCheckpoint[];
|
||||
}): void;
|
||||
|
||||
+26
-74
@@ -2,16 +2,15 @@
|
||||
* Practice-module core — the chain bijection + coverage (slice 1 of the
|
||||
* 2026-09-29 plan). Pure, no I/O.
|
||||
*
|
||||
* chainsForSelection(): the graph → chains translation — group by
|
||||
* conclusion over FOLLOWS edges only (supports are invisible to practice,
|
||||
* owner 2026-09-29: "no edges"). chainsToGraph(): the exact inverse — the
|
||||
* bijection the chain notation promises (renderable back to the graph
|
||||
* with no information loss; the round-trip is pinned in
|
||||
* tests/practice.test.ts). chainCoverage(): the set's completeness law —
|
||||
* the union of the problems' chains must equal the set's chains; the
|
||||
* partitioner's submit is validated against it.
|
||||
* chainsForSelection(): the stored CHAINS (chains-primary, owner
|
||||
* 2026-09-30) filtered to a selection — a chain rides along iff ALL of
|
||||
* its members are selected; nodes in no surviving chain become singleton
|
||||
* chains. formatChainLine(): one world-facing line per chain.
|
||||
* chainCoverage(): the set's completeness law — the union of the
|
||||
* problems' chains must equal the set's chains; the 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
|
||||
* 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." },
|
||||
};
|
||||
|
||||
/** The selection's follows-edges as chains: every edge into a node joins
|
||||
* that node's (one) chain; a node with no in- AND no out-follows-edge in
|
||||
* the selection is a singleton chain. Premise-only nodes are NOT separate
|
||||
* chains — they are covered inside the chains they feed. Deterministic:
|
||||
* chains sorted by id, premises sorted. */
|
||||
/** The stored chains filtered to a selection (chains-primary, owner
|
||||
* 2026-09-30): a chain rides along iff its conclusion AND every premise
|
||||
* are selected; a node in no surviving chain is a singleton chain
|
||||
* (premises: []). Premise-only nodes whose chain was excluded are
|
||||
* 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(
|
||||
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
|
||||
edges: ReadonlyArray<Pick<GraphEdge, "from" | "to" | "type">>,
|
||||
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
|
||||
): ReasoningChain[] {
|
||||
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 chains: ReasoningChain[] = [];
|
||||
for (const [conclusion, premises] of premisesByConclusion) {
|
||||
chains.push({ id: conclusion, premises: [...premises].sort(), conclusion });
|
||||
chained.add(conclusion);
|
||||
for (const premise of premises) chained.add(premise);
|
||||
const included: ReasoningChain[] = [];
|
||||
for (const chain of chains) {
|
||||
if (!selected.has(chain.conclusion)) continue;
|
||||
if (!chain.premises.every((premise) => selected.has(premise))) continue;
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
}
|
||||
|
||||
/** A chain as one world-facing line — the propositions' NAMES joined
|
||||
|
||||
@@ -54,19 +54,6 @@ export interface GraphRenderSettings {
|
||||
labelZoom: number;
|
||||
/** Node + hover label font size, WORLD units (scales with camera). */
|
||||
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). */
|
||||
clusterLabels: boolean;
|
||||
/** 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),
|
||||
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),
|
||||
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:
|
||||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
||||
clusterLabelSize: ladd(
|
||||
@@ -283,8 +262,6 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
|
||||
arrowLength: 2,
|
||||
labelZoom: 4,
|
||||
labelSize: 3,
|
||||
showSupports: true,
|
||||
supportsWidth: 0.5,
|
||||
clusterLabels: true,
|
||||
clusterLabelSize: 14,
|
||||
regions: DEFAULT_REGION_TUNING,
|
||||
|
||||
Reference in New Issue
Block a user