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:
+53
-53
@@ -33,7 +33,7 @@ import type {
|
||||
AuditVerdicts,
|
||||
CoverageEntry,
|
||||
ExtractionRunUsage,
|
||||
GraphEdge,
|
||||
GraphChain,
|
||||
GraphNode,
|
||||
|
||||
NotesSource,
|
||||
@@ -74,7 +74,7 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
|
||||
function memoryLedgers() {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const chains: GraphChain[] = [];
|
||||
const checkpoints: SessionCheckpoint[] = [];
|
||||
const coverage: CoverageEntry[] = [];
|
||||
const sources = new Map<string, NotesSource>();
|
||||
@@ -82,8 +82,8 @@ function memoryLedgers() {
|
||||
return {
|
||||
appendNode: (n: GraphNode) => void nodes.push(n),
|
||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
||||
appendEdge: (e: GraphEdge) => void edges.push(e),
|
||||
readEdges: () => edges.map((e) => ({ ...e })),
|
||||
appendChain: (c: GraphChain) => void chains.push(c),
|
||||
readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
|
||||
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
||||
readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
|
||||
// Extraction coverage (vision-extraction-coverage.md) — append-only;
|
||||
@@ -114,7 +114,7 @@ function memoryLedgers() {
|
||||
// store: replace the provided ledgers' full contents.
|
||||
rewriteLedgers: (next) => {
|
||||
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.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 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 () => {
|
||||
@@ -402,7 +402,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
extractor,
|
||||
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 () => {
|
||||
@@ -436,7 +436,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// p1 creates no node, p2 creates one, and the extractor's edge
|
||||
// resolves through them. (The old echo form could silently drop
|
||||
// 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
|
||||
// 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),
|
||||
});
|
||||
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,
|
||||
// so the post-merge naming garnish is gone — no model spend, no
|
||||
// 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 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.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs");
|
||||
assert.equal(ledgers.readCoverage().length, 0);
|
||||
@@ -620,7 +620,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
extractor: silentModel(),
|
||||
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);
|
||||
|
||||
// THE isolation pin: a two-batch chat whose SECOND extractor call
|
||||
@@ -642,7 +642,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
engineFor: (role) =>
|
||||
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(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||
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");
|
||||
|
||||
gateOpen();
|
||||
assert.deepEqual(await runA, { nodes: 1, edges: 0 });
|
||||
assert.deepEqual(await runB, { nodes: 1, edges: 0 });
|
||||
assert.deepEqual(await runA, { nodes: 1, chains: 0 });
|
||||
assert.deepEqual(await runB, { nodes: 1, chains: 0 });
|
||||
assert.ok(bAuditorCalled);
|
||||
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");
|
||||
@@ -774,7 +774,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
|
||||
// The dock's abort button, pressed while B queues.
|
||||
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(!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");
|
||||
@@ -782,14 +782,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// The chain link B abandoned still resolved: A finishes, and a
|
||||
// THIRD run audits normally — no deadlock residue.
|
||||
gateOpen();
|
||||
assert.deepEqual(await runA, { nodes: 1, edges: 0 });
|
||||
assert.deepEqual(await runA, { nodes: 1, chains: 0 });
|
||||
const pipelineC = createExtractionPipeline({
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")),
|
||||
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 () => {
|
||||
@@ -862,7 +862,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const stats = await runPromise;
|
||||
|
||||
// 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(ledgers.readNodes().length, 0, "nothing merged");
|
||||
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 second = await pipeline.runExtraction(chatId); // in-flight → zeros
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0 });
|
||||
assert.deepEqual(second, { nodes: 0, chains: 0 });
|
||||
|
||||
release();
|
||||
// The gated extractor never submits — an ISOLATED job failure now
|
||||
// (per-chunk jobs, owner 2026-09-28): the run resolves with zeros
|
||||
// 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 () => {
|
||||
@@ -959,10 +959,10 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
);
|
||||
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.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();
|
||||
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(!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 ledgers = memoryLedgers();
|
||||
const extractor = submitModel("submit_findings", {
|
||||
@@ -1044,16 +1044,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
||||
extractor,
|
||||
auditor: submitModel("submit_review", { links: [] }),
|
||||
});
|
||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, edges: 2 });
|
||||
// Both edges point INTO the conclusion's resolved node; the two
|
||||
// from-ends are exactly the two premises (node ids are random —
|
||||
// assert set-wise, never by ledger order).
|
||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, chains: 1 });
|
||||
// The stored chain's premises are exactly the two premises; the
|
||||
// conclusion is C's resolved node (ids are random — assert
|
||||
// set-wise, never by ledger order).
|
||||
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
|
||||
const conclusion = byName.get("C")!;
|
||||
const edges = ledgers.readEdges();
|
||||
assert.ok(edges.every((e) => e.to === conclusion), "both edges point INTO the conclusion");
|
||||
const chains = ledgers.readChains();
|
||||
assert.ok(chains.every((c) => c.conclusion === conclusion), "chains point INTO the conclusion");
|
||||
assert.deepEqual(
|
||||
edges.map((e) => e.from).sort(),
|
||||
chains.flatMap((c) => c.premises).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,
|
||||
auditor: submitModel("submit_review", { links: [] }),
|
||||
});
|
||||
// Independent arguments for the same conclusion are separate chains
|
||||
// → two edges into C; the bare p4 rides the propositions list alone.
|
||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, edges: 2 });
|
||||
// Independent arguments for the same conclusion are separate chains;
|
||||
// the bare p4 rides the propositions list alone.
|
||||
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, chains: 2 });
|
||||
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 () => {
|
||||
@@ -1099,7 +1099,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
||||
extractor,
|
||||
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 () => {
|
||||
@@ -1127,7 +1127,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
||||
graphStore: ledgers,
|
||||
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 () => {
|
||||
@@ -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 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 () => {
|
||||
@@ -1161,7 +1161,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
||||
extractor: retryEngine(bad, FINDINGS),
|
||||
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 () => {
|
||||
@@ -1177,7 +1177,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
|
||||
extractor: retryEngine(bad, FINDINGS),
|
||||
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", () => {
|
||||
@@ -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 });
|
||||
|
||||
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.
|
||||
assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]);
|
||||
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);
|
||||
// cross-chunk identity is the auditor's linkTo verdict, and these
|
||||
// fixtures submit none.
|
||||
assert.deepEqual(stats, { nodes: calls, edges: 0 });
|
||||
assert.deepEqual(stats, { nodes: calls, chains: 0 });
|
||||
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
|
||||
// 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.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),
|
||||
});
|
||||
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
|
||||
// at all (no model calls, no usage record; the mappings ledger
|
||||
// used to give this no-op at merge time by accident).
|
||||
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()[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
|
||||
// identity on a real model).
|
||||
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.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;
|
||||
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.readCheckpoints().length, 0, "nothing checkpointed");
|
||||
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)
|
||||
// — its propositions merge again as fresh nodes. Cross-batch
|
||||
// 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.
|
||||
const coverage = ledgers.readCoverage();
|
||||
@@ -1474,7 +1474,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
// new usage entry (no money spent on covered ground).
|
||||
const before = views.length;
|
||||
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);
|
||||
// PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job
|
||||
// (two), and the no-op run adds none.
|
||||
@@ -1521,7 +1521,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
const stats = await runPromise;
|
||||
|
||||
// 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");
|
||||
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
|
||||
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.
|
||||
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(ledgers.readNodes().length, 0, "nothing merged");
|
||||
// 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
|
||||
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1730,12 +1730,12 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
||||
// emissions (stats present but empty — nothing merged).
|
||||
assert.ok(stats);
|
||||
assert.equal(stats.nodes, 0);
|
||||
assert.equal(stats.edges, 0);
|
||||
assert.equal(stats.chains, 0);
|
||||
|
||||
// Nothing merged, nothing checkpointed — the deleted chat leaves no
|
||||
// trace for a later snapshot to resurrect.
|
||||
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.readCheckpoints().length, 0);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createProberTools } from "../src/agent/tools.ts";
|
||||
import { auditorGraphTools } from "../src/agent/tools/graph.ts";
|
||||
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,10 +39,10 @@ function fakeStores(
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
} = {},
|
||||
): { chatStore: ChatStore; graphStore: GraphStore } {
|
||||
const edges: GraphEdge[] = [];
|
||||
const chains: GraphChain[] = [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readChains: () => chains,
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
readSources: () => [],
|
||||
|
||||
+56
-51
@@ -243,11 +243,10 @@ function fixtureFindings(): SessionFindings {
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
chains: [
|
||||
{
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
type: "follows",
|
||||
premises: ["p1"],
|
||||
conclusion: "p2",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -274,14 +273,14 @@ function scopedGraph(chatIds: string[]) {
|
||||
const graph = getGraph(graphStore);
|
||||
return {
|
||||
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)", () => {
|
||||
it("creates nodes from the verdicts with chat-level provenance", () => {
|
||||
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]);
|
||||
assert.equal(graph.nodes.length, 3);
|
||||
@@ -298,13 +297,13 @@ describe("the merger (mergeFindings)", () => {
|
||||
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
|
||||
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||
|
||||
// The follows edge, provenance attached.
|
||||
assert.equal(graph.edges.length, 1);
|
||||
const edge = graph.edges[0]!;
|
||||
assert.equal(edge.from, idByStatement.get("Alpha intuition"));
|
||||
assert.equal(edge.to, idByStatement.get("Beta rule"));
|
||||
assert.equal(edge.type, "follows");
|
||||
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||
// The stored chain, provenance attached.
|
||||
assert.equal(graph.chains.length, 1);
|
||||
const chain = graph.chains[0]!;
|
||||
assert.match(chain.id, /^arg_/, "store-minted argument id");
|
||||
assert.deepEqual(chain.premises, [idByStatement.get("Alpha intuition")]);
|
||||
assert.equal(chain.conclusion, idByStatement.get("Beta rule"));
|
||||
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: "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));
|
||||
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));
|
||||
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]);
|
||||
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
|
||||
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: [] },
|
||||
],
|
||||
edges: [],
|
||||
chains: [],
|
||||
};
|
||||
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", () => {
|
||||
@@ -373,7 +372,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: ["alpha intuition"],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
chains: [],
|
||||
},
|
||||
// VERDICTS-ONLY: the identity call is a link entry, not a field
|
||||
// on the proposition (owner, 2026-09-28).
|
||||
@@ -381,7 +380,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
graphDeps,
|
||||
sessionSource(chat2.id),
|
||||
);
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
||||
assert.deepEqual(stats, { nodes: 0, chains: 0 });
|
||||
|
||||
const after = scopedGraph([chat1.id, chat2.id]);
|
||||
assert.equal(after.nodes.length, 3, "no duplicate node");
|
||||
@@ -410,7 +409,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
chains: [],
|
||||
},
|
||||
{ links: [] },
|
||||
graphDeps,
|
||||
@@ -439,10 +438,9 @@ describe("the merger (mergeFindings)", () => {
|
||||
statement,
|
||||
aliases: [],
|
||||
})),
|
||||
edges: [
|
||||
{ from: "p1", to: "p2", type: "follows", },
|
||||
{ from: "p2", to: "p3", type: "follows", },
|
||||
{ from: "p1", to: "p3", type: "follows", },
|
||||
chains: [
|
||||
{ premises: ["p1"], conclusion: "p2", },
|
||||
{ premises: ["p2"], conclusion: "p3", },
|
||||
],
|
||||
},
|
||||
{ links: [] },
|
||||
@@ -494,7 +492,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
chains: [],
|
||||
},
|
||||
// empty verdicts — identity is the auditor's call alone
|
||||
{ links: [] },
|
||||
@@ -519,13 +517,13 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
chains: [],
|
||||
},
|
||||
{ links: [] },
|
||||
graphDeps,
|
||||
{ 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"))!;
|
||||
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: "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));
|
||||
// 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: "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));
|
||||
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");
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 1,
|
||||
edgesRemoved: 1,
|
||||
chainsRemoved: 1,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
} satisfies ChatDeletionStats);
|
||||
@@ -684,9 +682,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
|
||||
// Z (dB-only) is untouched, byte-identical.
|
||||
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]);
|
||||
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.
|
||||
const positions = graphStore.readPositions();
|
||||
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: "" }] });
|
||||
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
|
||||
// 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", () => {
|
||||
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
|
||||
const before = {
|
||||
nodes: graphStore.readNodes(),
|
||||
edges: graphStore.readEdges(),
|
||||
chains: graphStore.readChains(),
|
||||
positions: graphStore.readPositions(),
|
||||
};
|
||||
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 0,
|
||||
edgesRemoved: 0,
|
||||
chainsRemoved: 0,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
} satisfies ChatDeletionStats);
|
||||
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.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
|
||||
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
|
||||
* provenance — a legacy line; the mappings anchor is gone, owner
|
||||
* 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() {
|
||||
// Clean slate per test: each test in this describe builds its own
|
||||
// scenario from empty ledgers (the suite's isolation covers the
|
||||
// whole file; this covers describe-internal stacking).
|
||||
graphStore.rewriteLedgers({ nodes: [], edges: [] });
|
||||
graphStore.rewriteLedgers({ nodes: [], chains: [] });
|
||||
graphStore.writePositions({});
|
||||
graphStore.appendNode({
|
||||
id: "n_dead",
|
||||
@@ -802,18 +803,20 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
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.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
// Resting positions for both.
|
||||
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
|
||||
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.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" }] });
|
||||
// A FOREIGN chain the deletion must not touch (destructive rewriting
|
||||
// may not exceed the blast radius — foreign data rides untouched).
|
||||
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", () => {
|
||||
buildNodeScenario();
|
||||
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
||||
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
|
||||
// (full death is node-scoped — other nodes' sessions keep their
|
||||
@@ -824,15 +827,17 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
keepLineBefore,
|
||||
"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(
|
||||
graphStore.readEdges().map((e) => [e.from, e.to]),
|
||||
[["n_keep", "n_other"]],
|
||||
graphStore.readChains().map((c) => [c.premises, c.conclusion]),
|
||||
[[["n_keep"], "n_other"]],
|
||||
);
|
||||
// The resting position died with the node.
|
||||
const positions = graphStore.readPositions();
|
||||
assert.equal(positions["n_dead"], undefined);
|
||||
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.
|
||||
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
|
||||
// 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" }],
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -899,7 +904,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
const revisionBefore = graphRevision();
|
||||
const outcome = deleteGraphNode("n_orch");
|
||||
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 >= revisionBefore, "the version stamp moved");
|
||||
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 { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.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 => ({
|
||||
id,
|
||||
@@ -18,7 +18,9 @@ const node = (id: string): GraphNode => ({
|
||||
createdAt: "2026-09-13T00:00:00.000Z",
|
||||
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 } => ({
|
||||
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 store = {
|
||||
readNodes: () => lines.map((l) => ({ ...l })),
|
||||
readEdges: () => [edge("a", "b")],
|
||||
readChains: () => [chain("arg_ab", ["a"], "b")],
|
||||
readPositions: () => ({} as Record<string, Position>),
|
||||
writePositions: (p: Record<string, Position>) => void written.push(p),
|
||||
};
|
||||
settleGraphLayout(store);
|
||||
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", () => {
|
||||
const written: Record<string, Position>[] = [];
|
||||
settleGraphLayout({
|
||||
readNodes: () => [],
|
||||
readEdges: () => [],
|
||||
readChains: () => [],
|
||||
readPositions: () => ({} as Record<string, Position>),
|
||||
writePositions: (p) => void written.push(p),
|
||||
});
|
||||
|
||||
@@ -27,18 +27,18 @@ import {
|
||||
type RunningPractice,
|
||||
} from "../src/agent/subagents/practice-pipeline.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 { ModelsNotConfiguredError } from "../src/core/settings.ts";
|
||||
|
||||
// ─── in-memory stores ────────────────────────────────────────────────────────
|
||||
|
||||
function memoryGraph(nodes: GraphNode[], edges: GraphEdge[]): GraphStore {
|
||||
function memoryGraph(nodes: GraphNode[], chains: GraphChain[]): GraphStore {
|
||||
return {
|
||||
appendNode: () => {},
|
||||
readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })),
|
||||
appendEdge: () => {},
|
||||
readEdges: () => edges.map((e) => ({ ...e, provenance: [...e.provenance] })),
|
||||
appendChain: () => {},
|
||||
readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
|
||||
appendCheckpoint: () => {},
|
||||
readCheckpoints: () => [],
|
||||
appendCoverage: () => {},
|
||||
@@ -284,15 +284,16 @@ function diamondGraph(): GraphStore {
|
||||
createdAt: NOW,
|
||||
provenance: pt("chat_1"),
|
||||
});
|
||||
const edge = (from: string, to: string, type: "follows" | "supports" = "follows"): GraphEdge => ({
|
||||
from,
|
||||
to,
|
||||
type,
|
||||
const chain = (from: string, to: string): GraphChain => ({
|
||||
id: to, // fixture id = conclusion id (the form the generator prompt renders)
|
||||
premises: [from],
|
||||
conclusion: to,
|
||||
provenance: pt("chat_1"),
|
||||
createdAt: NOW,
|
||||
});
|
||||
return memoryGraph(
|
||||
[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.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.
|
||||
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 —
|
||||
* chainsForSelection (group by conclusion, FOLLOWS ONLY — supports are
|
||||
* invisible to practice, owner ruling) / chainsToGraph (the exact inverse,
|
||||
* round-trip pinned) — and chainCoverage (the set's completeness law:
|
||||
* union of the problems' chains must equal the set's chains).
|
||||
* PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
|
||||
* (chains-primary, owner 2026-09-30: a chain rides along iff every member
|
||||
* is selected; nodes in no surviving chain are singletons) and
|
||||
* chainCoverage (the set's completeness law: union of the problems' chains
|
||||
* must equal the set's chains).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { EdgeType } from "../src/core/contracts.ts";
|
||||
import { chainCoverage, chainsForSelection, chainsToEdges, chainsToGraph, formatChainLine } from "../src/core/practice.ts";
|
||||
import type { GraphChain } from "../src/core/contracts.ts";
|
||||
import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
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. */
|
||||
const diamond = {
|
||||
nodes: [n("a"), n("b"), n("c"), n("d")],
|
||||
edges: [e("a", "b"), e("a", "c"), e("b", "d"), e("c", "d")],
|
||||
};
|
||||
const diamondChains = [
|
||||
{ id: "b", premises: ["a"], conclusion: "b" },
|
||||
{ id: "c", premises: ["a"], conclusion: "c" },
|
||||
{ id: "d", premises: ["b", "c"], conclusion: "d" },
|
||||
];
|
||||
/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
|
||||
* arguments (each its own arg_ id, conclusion ids may repeat). */
|
||||
const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
|
||||
const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
|
||||
|
||||
// ─── chainsForSelection ──────────────────────────────────────────────────────
|
||||
|
||||
describe("chainsForSelection", () => {
|
||||
it("groups by conclusion: the diamond a→b, a→c, b→d, c→d is three chains", () => {
|
||||
assert.deepEqual(chainsForSelection(diamond.nodes, diamond.edges), diamondChains);
|
||||
it("keeps every fully-selected stored argument, each under its own id", () => {
|
||||
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", () => {
|
||||
const ids = chainsForSelection(diamond.nodes, diamond.edges).map((c) => c.id);
|
||||
it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
|
||||
const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
|
||||
assert.ok(!ids.includes("a")); // a is only ever a premise
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
it("supports edges are NO edges — nodes joined only by supports are singletons", () => {
|
||||
const chains = chainsForSelection([n("x"), n("y")], [e("x", "y", "supports")]);
|
||||
it("two independent arguments for one conclusion stay DISTINCT chains", () => {
|
||||
// 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, [
|
||||
{ id: "x", premises: [], conclusion: "x" },
|
||||
{ id: "y", premises: [], conclusion: "y" },
|
||||
{ id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
|
||||
{ id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("an edge with an endpoint outside the selection is excluded entirely", () => {
|
||||
// a→out would chain a; out is not selected, so the edge is gone and
|
||||
it("an argument with ANY member outside the selection is excluded entirely", () => {
|
||||
// arg_ab needs b; b is not selected, so the argument is gone and
|
||||
// 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" }]);
|
||||
});
|
||||
|
||||
it("duplicate edge assertions collapse into one premise", () => {
|
||||
const chains = chainsForSelection([n("a"), n("b")], [e("a", "b"), e("a", "b")]);
|
||||
assert.deepEqual(chains, [{ id: "b", premises: ["a"], conclusion: "b" }]);
|
||||
it("a chain referencing unknown node ids simply never applies", () => {
|
||||
const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "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(
|
||||
[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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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" },
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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", () => {
|
||||
it("complete cover: every chain exercised, no missing", () => {
|
||||
const set = {
|
||||
chains: diamondChains,
|
||||
problems: [{ chains: ["b", "c"] }, { chains: ["d"] }],
|
||||
problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
|
||||
};
|
||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
||||
});
|
||||
@@ -117,23 +109,23 @@ describe("chainCoverage", () => {
|
||||
it("missing: a chain no problem exercises", () => {
|
||||
const set = {
|
||||
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", () => {
|
||||
const set = {
|
||||
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", () => {
|
||||
const set = {
|
||||
chains: [{ id: "b" }] as Array<{ id: string }>,
|
||||
problems: [{ chains: ["b", "ghost"] }],
|
||||
chains: [{ id: "arg_b" }] as Array<{ id: string }>,
|
||||
problems: [{ chains: ["arg_b", "ghost"] }],
|
||||
};
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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 { ChainTableBody } 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:
|
||||
* 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),
|
||||
];
|
||||
|
||||
const EDGES: GraphEdge[] = [
|
||||
{ from: "a", to: "c1", type: "follows" },
|
||||
{ from: "b", to: "c1", type: "follows" },
|
||||
{ from: "d", to: "e", type: "follows" },
|
||||
] as GraphEdge[];
|
||||
const CHAINS: GraphChain[] = [
|
||||
{ id: "c1", premises: ["a", "b"], conclusion: "c1", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
|
||||
{ id: "e", premises: ["d"], conclusion: "e", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
|
||||
];
|
||||
|
||||
const BASE = {
|
||||
nodes: NODES,
|
||||
edges: EDGES,
|
||||
chains: CHAINS,
|
||||
sessions: { chat1: "Ethics lesson", chat2: "Second chat" },
|
||||
fileSources: { notes1: "notes/raw/ethics.md" },
|
||||
query: "",
|
||||
|
||||
@@ -118,8 +118,6 @@ describe("sanitizeRenderSettings", () => {
|
||||
arrowLength: 3,
|
||||
labelZoom: 2,
|
||||
labelSize: 4,
|
||||
showSupports: false,
|
||||
supportsWidth: 0.3,
|
||||
clusterLabels: false,
|
||||
clusterLabelSize: 20,
|
||||
regions: DEFAULT_REGION_TUNING,
|
||||
@@ -128,27 +126,13 @@ describe("sanitizeRenderSettings", () => {
|
||||
assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member
|
||||
});
|
||||
|
||||
test("showSupports (2026-09-29): boolean-only, default on — the draw toggle", () => {
|
||||
// A render-only hide for supports edges: anything but a real boolean
|
||||
// (missing key, legacy file, garbage) loads as ON (today's look); a
|
||||
// real false round-trips untouched.
|
||||
assert.equal(sanitizeRenderSettings({}).showSupports, true);
|
||||
assert.equal(sanitizeRenderSettings({ showSupports: false }).showSupports, false);
|
||||
assert.equal(sanitizeRenderSettings({ showSupports: "no" }).showSupports, true);
|
||||
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("supports settings are GONE (chains-primary, 2026-09-30): stale keys silently drop", () => {
|
||||
// Supports edges were retired; showSupports/supportsWidth no longer
|
||||
// exist. A legacy file carrying them loads without the keys — the
|
||||
// sanitizer drops unknown fields by construction.
|
||||
const cleaned = sanitizeRenderSettings({ showSupports: false, supportsWidth: 0.9 } as never);
|
||||
assert.equal("showSupports" in cleaned, false);
|
||||
assert.equal("supportsWidth" in cleaned, false);
|
||||
});
|
||||
|
||||
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 { 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 {
|
||||
graphSessionIndex,
|
||||
@@ -40,10 +40,10 @@ function fakeStores(
|
||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||
} = {},
|
||||
): GraphDeps {
|
||||
const edges: GraphEdge[] = [];
|
||||
const chains: GraphChain[] = [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readChains: () => chains,
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
readSources: () =>
|
||||
|
||||
+2
-2
@@ -244,7 +244,7 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl
|
||||
startedAt,
|
||||
at: "2026-09-09T00:00:00Z",
|
||||
durationMs: 42_000,
|
||||
stats: { nodes: 1, edges: 2 },
|
||||
stats: { nodes: 1, chains: 2 },
|
||||
stages: {
|
||||
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 })] },
|
||||
@@ -281,7 +281,7 @@ test("appendSourceUsage: appends to the source record, idempotent per startedAt,
|
||||
startedAt,
|
||||
at: "2026-09-13T00:00:00Z",
|
||||
durationMs: 42_000,
|
||||
stats: { nodes: 1, edges: 2 },
|
||||
stats: { nodes: 1, chains: 2 },
|
||||
stages: {
|
||||
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 })] },
|
||||
|
||||
Reference in New Issue
Block a user