extraction cleanup: events ledger dies, markdown source view, auditor-as-linker (2026-09-27 rulings)
The graph becomes raw reasoning chains only. Quiz outcomes and understanding observations are no longer formalized; assessment moves to the future practice module. Events ledger removed end-to-end: LedgerEvent/LedgerEventKind/ UnderstandingLevel/ExtractionAttribution/AuditResult.dropped and snapshot nodeObservations deleted from contracts; quizEvent builder and the events step of mergeFindings gone; appendEvent/readEvents dropped from the port and store; LEDGER_FILES loses events.jsonl with a one-time purge of a stale file next to the nodes ledger; graph-deletion and the delete dialog lose event purge + quiz-answer impact counts; GraphSessionProposition becomes a pure birth list. Whole-session markdown source view: new core/session-markdown.ts is the shared formatting engine (id-anchored speaker blocks, quiz/ recall/question blocks, tool one-liners) — the extractor's prompt view and the future user-facing export render from one engine. Coverage fingerprints deliberately stay on the old line format (content-hash semantics; re-formatting never re-opens covered history). The whole session goes to the extractor — no phase cut. Auditor refocused as the identity linker: never sees the source; explores the graph via new auditor-specific tools in agent/tools/graph.ts (find — glob over name/statement/aliases; list_sessions; list_propositions with full statement text; no timestamps) instead of an injected full-graph dump; equivalence bias — paraphrases merge, only genuinely distinct propositions stay apart; no drop channel, the extractor is fully trusted; may draw bridging edges (typed follows/supports) between existing nodes and the batch, resolved by the merger with chat-scoped message-less provenance. Agentic loop, maxTurns 16. UI: the node detail card gains an edges section above the sessions list (follows-from / supported-by / what-follows / what-it-supports, rendered only when present, click-to-select neighbors); the understanding-note chips are gone. Prober list_propositions reworded to what a session covered — names only, no levels. docs/architecture.md + README updated to the as-built pipeline. Gates: typecheck, 1059/1059, lint, build.
This commit is contained in:
+66
-114
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* The extraction pipeline tests (vision §1 construction, 2026-09-02 redesign):
|
||||
* extractor → auditor → merger, the sole graph writer — tree-blind,
|
||||
* free-form, with the auditor owning faithfulness and identity.
|
||||
* The extraction pipeline tests (vision §1 construction, 2026-09-02 redesign;
|
||||
* relinked 2026-09-27): extractor → auditor → merger, the sole graph writer —
|
||||
* the extractor alone sees the source (whole-session markdown), the auditor
|
||||
* never does (identity linking through graph tools), the merger is
|
||||
* deterministic.
|
||||
*
|
||||
* transcriptView — the compact view both LLM stages see (pure):
|
||||
* message-id-tagged verbatim text + quiz lines, NO tree
|
||||
* transcriptView — the markdown rendering the extractor sees (pure):
|
||||
* id-anchored Teacher/Learner blocks + check blocks
|
||||
* pipeline — scripted faux engines drive both agents: findings out,
|
||||
* audit verdict (with linkTo identity) in, deterministic
|
||||
* merge applied; failure paths checkpoint nothing;
|
||||
* single-flight per chat
|
||||
*
|
||||
* The merger itself (identity verdicts, event rebuild, idempotency) is
|
||||
* tested in tests/graph.test.ts directly against mergeFindings — here it is
|
||||
* the integration: what one full pipeline run does to the ledgers.
|
||||
* The merger itself (identity verdicts, idempotency) is tested in
|
||||
* tests/graph.test.ts directly against mergeFindings — here it is the
|
||||
* integration: what one full pipeline run does to the ledgers.
|
||||
*
|
||||
* Ledger: in-memory (the GraphStore port) — this suite never touches
|
||||
* data/graph. Chats: real ChatRecords in the real chat store (created and
|
||||
@@ -33,7 +35,6 @@ import type {
|
||||
ExtractionRunUsage,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
LedgerEvent,
|
||||
NodeMapping,
|
||||
NotesSource,
|
||||
SessionCheckpoint,
|
||||
@@ -42,7 +43,7 @@ import type {
|
||||
import type { GraphStore } from "../src/core/ports.ts";
|
||||
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
|
||||
import { batchTag, coverageOf, coverageSlots, messageViewLines } from "../src/core/coverage.ts";
|
||||
import { coverageOf, messageViewLines } from "../src/core/coverage.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -64,7 +65,6 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
function memoryLedgers() {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const events: LedgerEvent[] = [];
|
||||
const mappings: NodeMapping[] = [];
|
||||
const checkpoints: SessionCheckpoint[] = [];
|
||||
const coverage: CoverageEntry[] = [];
|
||||
@@ -75,8 +75,6 @@ function memoryLedgers() {
|
||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
||||
appendEdge: (e: GraphEdge) => void edges.push(e),
|
||||
readEdges: () => edges.map((e) => ({ ...e })),
|
||||
appendEvent: (e: LedgerEvent) => void events.push(e),
|
||||
readEvents: () => events.map((e) => ({ ...e })),
|
||||
appendMapping: (m: NodeMapping) => void mappings.push(m),
|
||||
readMappings: () => mappings.map((m) => ({ ...m })),
|
||||
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
||||
@@ -110,7 +108,6 @@ function memoryLedgers() {
|
||||
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.events) events.splice(0, events.length, ...next.events.map((e) => ({ ...e })));
|
||||
if (next.mappings) mappings.splice(0, mappings.length, ...next.mappings.map((m) => ({ ...m })));
|
||||
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 })));
|
||||
@@ -224,7 +221,7 @@ after(() => {
|
||||
});
|
||||
|
||||
/** What the extractor would produce for the fixture session — tree-blind,
|
||||
* free-form, with message-granular provenance + an understanding note. */
|
||||
* free-form, with message-granular provenance. */
|
||||
const FINDINGS: SessionFindings = {
|
||||
propositions: [
|
||||
{
|
||||
@@ -233,8 +230,7 @@ const FINDINGS: SessionFindings = {
|
||||
statement: "The derivative measures how fast a function changes",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
|
||||
understanding: "solid",
|
||||
evidence: ["The derivative measures how fast a function changes."],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
@@ -242,7 +238,7 @@ const FINDINGS: SessionFindings = {
|
||||
statement: "The chain rule differentiates composed functions by multiplying local rates.",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["LEARNER [u1]: teach me the chain rule"],
|
||||
evidence: ["teach me the chain rule"],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -251,10 +247,9 @@ const FINDINGS: SessionFindings = {
|
||||
to: "p2",
|
||||
type: "follows",
|
||||
messages: ["a2"],
|
||||
evidence: ["LEARNER [u1]: teach me the chain rule"],
|
||||
evidence: ["teach me the chain rule"],
|
||||
},
|
||||
],
|
||||
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
|
||||
};
|
||||
|
||||
/** An engine whose ONE response is the `name` tool call with the given
|
||||
@@ -301,27 +296,30 @@ function silentModel(): PiEngine {
|
||||
|
||||
// ─── transcriptView (pure) ───────────────────────────────────────────────────
|
||||
|
||||
describe("transcriptView (pure)", () => {
|
||||
it("shows message-id-tagged verbatim conversation + quiz lines — tree-blind, plan ops folded", () => {
|
||||
describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27)", () => {
|
||||
it("renders id-anchored speaker blocks + check blocks — tree-blind, plan ops folded", () => {
|
||||
const { record } = fixtureChat();
|
||||
const view = transcriptView(record);
|
||||
|
||||
assert.match(view, /SESSION: Extraction fixture/);
|
||||
// The document header is the session title.
|
||||
assert.match(view, /^# Extraction fixture$/m);
|
||||
|
||||
// Conversation verbatim, each line tagged with its message id —
|
||||
// the provenance anchors the extractor reports back.
|
||||
assert.match(view, /LEARNER \[u1\] teach me the chain rule/);
|
||||
assert.match(view, /TEACHER \[a2\] The derivative measures how fast a function changes\./);
|
||||
// Conversation verbatim, each block headed by its speaker + the
|
||||
// message-id anchor — the provenance the extractor reports back.
|
||||
assert.match(view, /### Learner · u1\n\nteach me the chain rule/);
|
||||
assert.match(view, /### Teacher · a2\n\nThe derivative measures how fast a function changes\./);
|
||||
|
||||
// Structured events, attribution-ready.
|
||||
assert.match(view, /QUIZ \[probe-1\] What is a derivative, roughly\? — the learner answered incorrectly/);
|
||||
assert.match(view, /QUIZ \[check-1\] Check — the learner answered correctly/);
|
||||
// Checks render as readable blocks (question + outcome).
|
||||
assert.match(view, /### Quiz · r_probe-1/);
|
||||
assert.match(view, /\*\*Q:\*\* What is a derivative, roughly\?/);
|
||||
assert.match(view, /The learner answered incorrectly\./);
|
||||
assert.match(view, /The learner answered correctly\./);
|
||||
|
||||
// TREE-BLIND: no plan tree, no tree edges, no mark_done lines — plan
|
||||
// ops fold to the generic one-liner like every other non-quiz tool.
|
||||
assert.doesNotMatch(view, /FINAL PLAN TREE/);
|
||||
assert.doesNotMatch(view, /TREE EDGES/);
|
||||
assert.match(view, /\(plan_init tool result delivered\)/);
|
||||
assert.match(view, /\*plan_init result delivered\*/);
|
||||
assert.doesNotMatch(view, /output-available/);
|
||||
});
|
||||
|
||||
@@ -341,9 +339,9 @@ describe("transcriptView (pure)", () => {
|
||||
]),
|
||||
].flat());
|
||||
const view = transcriptView(chatStore.getChat(chat.id)!);
|
||||
assert.match(view, /\(research tool result delivered\)/);
|
||||
assert.match(view, /\*research result delivered\*/);
|
||||
assert.doesNotMatch(view, /long brief/);
|
||||
assert.doesNotMatch(view, /TEACHER: {2}/);
|
||||
assert.doesNotMatch(view, /### Teacher/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,7 +353,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const ledgers = memoryLedgers();
|
||||
|
||||
// The auditor passes p1 through and LINKS it to a pre-existing node
|
||||
// (identity verdict), drops p2 (unbacked) + the edge with it.
|
||||
// (identity verdict); p2 stays a verdict-free create.
|
||||
const existingId = "n_existing";
|
||||
ledgers.appendNode({
|
||||
id: existingId,
|
||||
@@ -370,8 +368,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
{ ...FINDINGS.propositions[0]!, linkTo: existingId },
|
||||
],
|
||||
edges: [],
|
||||
attribution: { quiz: { "probe-1": "p1" } },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }, { ref: "p1→p2", reason: "endpoint dropped" }],
|
||||
};
|
||||
|
||||
const pipeline = createExtractionPipeline({
|
||||
@@ -382,38 +378,18 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
});
|
||||
const stats = await pipeline.runExtraction(chatId);
|
||||
|
||||
// The linked proposition created NO new node; the understanding note
|
||||
// became an observation; both quizzes became events.
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 3 });
|
||||
// The linked proposition created NO new node.
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
||||
|
||||
// Link-append: the phrasing joined the existing node's aliases and
|
||||
// the provenance unioned; no second node line defines a new node.
|
||||
assert.equal(ledgers.readNodes().length, 2); // the pre-seeded line + the link line
|
||||
assert.deepEqual(ledgers.readMappings(), [{ chatId, sourceId: "p1", graphNodeId: existingId }]);
|
||||
|
||||
// Events rebuilt deterministically: quizzes in transcript order, then
|
||||
// the observation. check-1's attribution pointed at the DROPPED p2 →
|
||||
// unresolvable → null (the question text is kept for later attribution).
|
||||
// The observation ref is BATCH-TAGGED (obs:<tag>:p1) so a re-run of
|
||||
// the same batch dedupes while a re-opened message's update lands as
|
||||
// a new event (vision-extraction-coverage.md).
|
||||
const events = ledgers.readEvents();
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const expectedTag = batchTag(coverageSlots(record.messages));
|
||||
assert.deepEqual(
|
||||
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
||||
[
|
||||
["miss", "probe-1", existingId],
|
||||
["correct", "check-1", null],
|
||||
["observation", `obs:${expectedTag}:p1`, existingId],
|
||||
],
|
||||
);
|
||||
assert.equal(events[0]!.nodeId, existingId, "probe attributed via the verdict's mapping");
|
||||
assert.equal(events[2]!.note, "solid", "the understanding note joins the ledger");
|
||||
|
||||
// COVERAGE checkpointed per message (not a whole-chat checkpoint):
|
||||
// one entry per transcript message, fingerprinted content each —
|
||||
// the same turns are never extracted twice.
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const covered = ledgers.readCoverage();
|
||||
// Wave D: the coverage unit is the VISIBLE native message — bonded
|
||||
// riders with no view lines (a textless call message) are context,
|
||||
@@ -498,12 +474,10 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
it("the LLM cluster-namer is DEAD (wave 7): a merge mints no naming runs — labels are session names now", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
// Identity-pass verdict: p1 becomes a new node, p2 dropped (no links).
|
||||
// Identity-pass verdict: p1 becomes a new node, p2 unlinked.
|
||||
const verdict: AuditResult = {
|
||||
propositions: [{ ...FINDINGS.propositions[0]! }],
|
||||
edges: [],
|
||||
attribution: { quiz: { "probe-1": "p1" } },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }],
|
||||
};
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
@@ -512,7 +486,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
auditor: submitModel("submit_review", verdict),
|
||||
});
|
||||
const stats = await pipeline.runExtraction(chatId);
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 3 });
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
// 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.
|
||||
@@ -525,9 +499,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const ledgers = memoryLedgers();
|
||||
const verdict: AuditResult = {
|
||||
propositions: FINDINGS.propositions,
|
||||
edges: [], // the edge is dropped with it
|
||||
attribution: FINDINGS.attribution,
|
||||
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
|
||||
edges: [],
|
||||
};
|
||||
const consulted: string[] = [];
|
||||
const pipeline = createExtractionPipeline({
|
||||
@@ -558,8 +530,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const verdict: AuditResult = {
|
||||
propositions: FINDINGS.propositions,
|
||||
edges: [],
|
||||
attribution: FINDINGS.attribution,
|
||||
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
|
||||
};
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
@@ -593,7 +563,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: silentModel(),
|
||||
auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }),
|
||||
auditor: submitModel("submit_review", FINDINGS),
|
||||
});
|
||||
await assert.rejects(failingExtractor.runExtraction(chatId), /the extractor never submitted findings/);
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
@@ -616,8 +586,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const verdict: AuditResult = {
|
||||
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }],
|
||||
};
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
@@ -648,8 +616,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const verdict: AuditResult = {
|
||||
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }],
|
||||
};
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
@@ -675,7 +641,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
assert.equal(record.extractions?.length, 1);
|
||||
const run = record.extractions![0]!;
|
||||
assert.equal(run.stats?.nodes, 0); // linked, not created
|
||||
assert.equal(run.stats?.events, 3);
|
||||
// the scripted engine's model, provider-qualified (model slots wave)
|
||||
assert.equal(run.stages.extractor.model, "faux:faux-1");
|
||||
assert.equal(run.stages.extractor.steps.length, 1); // the submit call — the run STOPS after a clean submit
|
||||
@@ -696,7 +661,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: hangingModel(), // hangs until aborted — no step finishes
|
||||
auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }),
|
||||
auditor: submitModel("submit_review", { propositions: [], edges: [] }),
|
||||
});
|
||||
const runPromise = pipeline.runExtraction(chatId);
|
||||
|
||||
@@ -706,7 +671,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, events: 0 });
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 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");
|
||||
@@ -751,7 +716,7 @@ 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, events: 0 });
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0 });
|
||||
|
||||
release();
|
||||
await assert.rejects(first, /the extractor never submitted findings/);
|
||||
@@ -800,7 +765,7 @@ 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, events: 0 }, "single-flight spans pipeline instances");
|
||||
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0 }, "single-flight spans pipeline instances");
|
||||
|
||||
release();
|
||||
await assert.rejects(first, /the extractor never submitted findings/);
|
||||
@@ -829,8 +794,8 @@ const NOTES_TEXT = `> [!quote] YOU
|
||||
> [!abstract] PI
|
||||
> Vim always starts in Normal mode — keys are commands, not text.`;
|
||||
|
||||
/** What the notes-mode extractor submits: same shapes minus attribution,
|
||||
* and messages always empty (the file is the provenance). */
|
||||
/** What the notes-mode extractor submits: same shapes as session findings,
|
||||
* messages always empty (the file is the provenance). */
|
||||
const NOTES_FINDINGS = {
|
||||
propositions: [
|
||||
{
|
||||
@@ -840,7 +805,6 @@ const NOTES_FINDINGS = {
|
||||
aliases: [],
|
||||
messages: [],
|
||||
evidence: ["Vim always starts in Normal mode — keys are commands, not text."],
|
||||
understanding: "solid",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -849,11 +813,10 @@ const NOTES_FINDINGS = {
|
||||
const NOTES_VERDICT = {
|
||||
propositions: [NOTES_FINDINGS.propositions[0]],
|
||||
edges: [],
|
||||
dropped: [],
|
||||
} as const;
|
||||
|
||||
describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
it("imports raw notes end to end: source record, file-scoped provenance, no quiz events", async () => {
|
||||
it("imports raw notes end to end: source record, file-scoped provenance", async () => {
|
||||
const ledgers = memoryLedgers();
|
||||
const seen: { kind?: string; label?: string }[] = [];
|
||||
const pipeline = createExtractionPipeline({
|
||||
@@ -868,7 +831,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, events: 1 });
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 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");
|
||||
@@ -888,14 +851,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
assert.equal(node.statement, "Vim starts in Normal mode, where keys are commands.");
|
||||
assert.deepEqual(node.provenance, []);
|
||||
assert.deepEqual(ledgers.readMappings(), [{ chatId: source.id, sourceId: "p1", graphNodeId: node.id }]);
|
||||
|
||||
// One observation event, zero quiz events — outcomes inside notes
|
||||
// surface only through understanding observations (user-decided).
|
||||
const events = ledgers.readEvents();
|
||||
assert.deepEqual(
|
||||
events.map((e) => [e.kind, e.chatId, e.ref, e.note]),
|
||||
[["observation", source.id, "obs:p1", "solid"]],
|
||||
);
|
||||
// The checkpoint stamps the import.
|
||||
assert.equal(ledgers.readCheckpoints()[0]!.chatId, source.id);
|
||||
});
|
||||
@@ -910,10 +865,10 @@ 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, events: 1 });
|
||||
assert.deepEqual(first, { nodes: 1, edges: 0 });
|
||||
|
||||
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0, events: 0 }, "the same source id dedupes everything");
|
||||
assert.deepEqual(second, { nodes: 0, edges: 0 }, "the same source id dedupes everything");
|
||||
|
||||
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
|
||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "both runs billed their usage");
|
||||
@@ -932,7 +887,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, events: 0 });
|
||||
assert.deepEqual(await runPromise, { nodes: 0, edges: 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");
|
||||
@@ -943,8 +898,8 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
// ── the chunked pipeline (extraction coverage — vision-extraction-coverage.md) ──
|
||||
|
||||
/** Pull the pipeline's prompt view out of a model call: the pipeline sends
|
||||
* `Transcript:\n\n<view>` as the user turn, so the LAST user message's
|
||||
* text IS what the extractor saw. */
|
||||
* the session's markdown document itself as the user turn, so the LAST
|
||||
* user message's text IS what the extractor saw. */
|
||||
function capturedView(context: unknown): string {
|
||||
const messages = (context as { messages?: { role: string; content: string }[] }).messages ?? [];
|
||||
const last = [...messages].reverse().find((m) => m.role === "user");
|
||||
@@ -972,8 +927,6 @@ function passThroughAuditStep(): FauxResponseStep {
|
||||
{
|
||||
propositions: [{ ...FINDINGS.propositions[0]! }],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }],
|
||||
} as unknown as ToolCall["arguments"],
|
||||
{ id: "review-call" },
|
||||
),
|
||||
@@ -1014,15 +967,16 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
// Two batches: the first held messages 1-3, the second 4-5 — a
|
||||
// message is never split, batches are consecutive oldest-first.
|
||||
assert.equal(views.length, 2, "one extractor call per batch");
|
||||
assert.ok(views[0]!.includes("LEARNER [u1]") && views[0]!.includes("LEARNER [u3]"));
|
||||
assert.ok(!views[0]!.includes("LEARNER [u4]"), "the batch stops at the token budget");
|
||||
assert.ok(views[1]!.includes("LEARNER [u4]") && views[1]!.includes("LEARNER [u5]"));
|
||||
assert.ok(!views[1]!.includes("LEARNER [u3]"));
|
||||
assert.ok(views[0]!.includes("### Learner · u1") && views[0]!.includes("### Learner · u3"));
|
||||
assert.ok(!views[0]!.includes("### Learner · u4"), "the batch stops at the token budget");
|
||||
assert.ok(views[1]!.includes("### Learner · u4") && views[1]!.includes("### Learner · u5"));
|
||||
assert.ok(!views[1]!.includes("### Learner · u3"));
|
||||
// The document header is the session title.
|
||||
assert.ok(views[0]!.startsWith("# Chunky coverage fixture"));
|
||||
|
||||
// p1 was a fresh node in batch 1; batch 2's p1 mapped to the same
|
||||
// node (chat-scoped mapping) — but its understanding note is a NEW
|
||||
// batch-tagged observation.
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 2 });
|
||||
// node (chat-scoped mapping).
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
|
||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||
const coverage = ledgers.readCoverage();
|
||||
@@ -1034,7 +988,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, events: 0 });
|
||||
assert.deepEqual(again, { nodes: 0, edges: 0 });
|
||||
assert.equal(views.length, before);
|
||||
assert.equal(chatStore.getChat(chatId)!.extractions?.length, 1, "the no-op run persists no usage");
|
||||
});
|
||||
@@ -1079,7 +1033,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: 1, edges: 0, events: 1 });
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
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");
|
||||
@@ -1095,8 +1049,8 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
});
|
||||
await finisher.runExtraction(chatId);
|
||||
assert.equal(reRunViews.length, 1);
|
||||
assert.ok(reRunViews[0]!.includes("LEARNER [u4]") && reRunViews[0]!.includes("LEARNER [u5]"));
|
||||
assert.ok(!reRunViews[0]!.includes("LEARNER [u1]"), "the covered remainder never re-enters the prompt");
|
||||
assert.ok(reRunViews[0]!.includes("### Learner · u4") && reRunViews[0]!.includes("### Learner · u5"));
|
||||
assert.ok(!reRunViews[0]!.includes("### Learner · u1"), "the covered remainder never re-enters the prompt");
|
||||
assert.equal(ledgers.readCoverage().length, 5, "the re-run closed the gap");
|
||||
});
|
||||
|
||||
@@ -1152,7 +1106,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
});
|
||||
await reOpener.runExtraction(chatId);
|
||||
assert.equal(reRunViews.length, 1, "one batch: the single re-opened message");
|
||||
assert.ok(reRunViews[0]!.includes("LEARNER [s1]") && !reRunViews[0]!.includes("LEARNER [s2]"));
|
||||
assert.ok(reRunViews[0]!.includes("### Learner · s1") && !reRunViews[0]!.includes("### Learner · s2"));
|
||||
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0, "re-covered");
|
||||
});
|
||||
});
|
||||
@@ -1170,7 +1124,7 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
extractor: submitModel("submit_findings", FINDINGS),
|
||||
auditor: submitModel("submit_review", { propositions: FINDINGS.propositions, edges: [], attribution: { quiz: {} }, dropped: [] }, () => chatStore.deleteChat(chatId)),
|
||||
auditor: submitModel("submit_review", { propositions: FINDINGS.propositions, edges: [] }, () => chatStore.deleteChat(chatId)),
|
||||
});
|
||||
const stats = await pipeline.runExtraction(chatId);
|
||||
|
||||
@@ -1179,13 +1133,11 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
|
||||
assert.ok(stats);
|
||||
assert.equal(stats.nodes, 0);
|
||||
assert.equal(stats.edges, 0);
|
||||
assert.equal(stats.events, 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.readEvents().length, 0);
|
||||
assert.equal(ledgers.readMappings().length, 0);
|
||||
assert.equal(ledgers.readCoverage().length, 0);
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
|
||||
+76
-41
@@ -1,19 +1,21 @@
|
||||
// THE PROBER'S GRAPH TOOLS (wave 7) — pinned behaviors:
|
||||
// THE PROBER'S GRAPH TOOLS (wave 7; understanding levels removed
|
||||
// 2026-09-27) — pinned behaviors:
|
||||
// 1. REGISTRATION: the factory registers list_sessions/list_propositions
|
||||
// on the prober ONLY when the composition root passes a graphStore
|
||||
// (the graphToolsEnabled gate lives server-side; absent store = closed
|
||||
// gate — the factory must not reach for settings itself).
|
||||
// 2. OUTPUT: names + levels + dating only — the owner ruling bars
|
||||
// statement text and session contents from the prober's view of the map.
|
||||
// 2. OUTPUT: names only — the owner ruling bars statement text and session
|
||||
// contents from the prober's view of the map.
|
||||
// 3. HONESTY: empty graph, unknown session id — the tools answer in
|
||||
// their own voice, never throw, never guess.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
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, LedgerEvent } from "../src/core/contracts/graph.ts";
|
||||
import type { GraphEdge, GraphNode } from "../src/core/contracts/graph.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,27 +33,18 @@ const node = (
|
||||
provenance,
|
||||
});
|
||||
|
||||
const observation = (
|
||||
nodeId: string,
|
||||
chatId: string,
|
||||
note: string,
|
||||
at: string,
|
||||
): LedgerEvent =>
|
||||
({ kind: "observation", nodeId, chatId, note, at }) as unknown as LedgerEvent;
|
||||
|
||||
function fakeStores(
|
||||
nodes: GraphNode[],
|
||||
over: {
|
||||
events?: LedgerEvent[];
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
|
||||
} = {},
|
||||
): { chatStore: ChatStore; graphStore: GraphStore } {
|
||||
const edges: GraphEdge[] = [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readEvents: () => over.events ?? [],
|
||||
readMappings: () => [],
|
||||
readMappings: () => over.mappings ?? [],
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
readSources: () => [],
|
||||
@@ -134,7 +127,6 @@ describe("list_sessions", () => {
|
||||
updatedAt: "2026-09-21T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
events: [],
|
||||
},
|
||||
);
|
||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||
@@ -157,38 +149,28 @@ describe("list_sessions", () => {
|
||||
});
|
||||
|
||||
describe("list_propositions", () => {
|
||||
it("names + latest understanding + that evaluation's date — the latest event wins", async () => {
|
||||
const { chatStore, graphStore } = fakeStores(
|
||||
[node("n1", "Water cycle", [pt("c1")]), node("n2", "Evaporation", [pt("c1")])],
|
||||
{
|
||||
events: [
|
||||
// The birth session's OWN evaluations only — a later
|
||||
// session's observation must not leak in here.
|
||||
observation("n1", "c1", "shaky", "2026-09-20T12:00:00.000Z"),
|
||||
observation("n1", "c1", "solid", "2026-09-22T12:00:00.000Z"),
|
||||
observation("n1", "c2", "resisted", "2026-09-23T12:00:00.000Z"),
|
||||
observation("n2", "c1", "not yet engaged", "2026-09-21T12:00:00.000Z"),
|
||||
],
|
||||
},
|
||||
);
|
||||
it("names only — what the session covered (2026-09-27: no levels, no dating)", async () => {
|
||||
const { chatStore, graphStore } = fakeStores([
|
||||
node("n1", "Water cycle", [pt("c1")]),
|
||||
node("n2", "Evaporation", [pt("c1")]),
|
||||
]);
|
||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||
assert.match(out, /2 proposition\(s\) born in this session:/);
|
||||
assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/);
|
||||
assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/);
|
||||
// Never the other session's word, never the statement text.
|
||||
assert.ok(!out.includes("resisted"));
|
||||
assert.match(out, /2 proposition\(s\) covered in this session:/);
|
||||
assert.match(out, /- Water cycle/);
|
||||
assert.match(out, /- Evaporation/);
|
||||
// The owner ruling: no levels, no dating, never the statement text.
|
||||
assert.ok(!out.includes("as of"));
|
||||
assert.ok(!out.includes("solid"));
|
||||
assert.ok(!out.includes("statement of"));
|
||||
});
|
||||
|
||||
it("a proposition with no observation reads 'never evaluated', undated", async () => {
|
||||
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])], {
|
||||
events: [],
|
||||
});
|
||||
it("no evaluation machinery at all — the covered list is the whole output", async () => {
|
||||
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||
assert.match(out, /- Water cycle — never evaluated/);
|
||||
assert.ok(!out.includes("(as of"));
|
||||
assert.match(out, /- Water cycle/);
|
||||
assert.ok(!out.includes("never evaluated"));
|
||||
});
|
||||
|
||||
it("an unknown session id — honest redirect, not a throw", async () => {
|
||||
@@ -198,3 +180,56 @@ describe("list_propositions", () => {
|
||||
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the auditor's toolset (2026-09-27 relink) ───────────────────────────────
|
||||
|
||||
describe("auditor graph tools", () => {
|
||||
const nodes = [
|
||||
{ ...node("n1", "Will to power", [pt("c1")]), statement: "The will to power is Nietzsche's central drive.", aliases: ["power drive"] },
|
||||
{ ...node("n2", "Eternal recurrence", [pt("c1")]), statement: "Eternal recurrence is the thought experiment of living again.", aliases: [] },
|
||||
];
|
||||
const { chatStore, graphStore } = fakeStores(nodes);
|
||||
|
||||
it("find: glob over names + statements + aliases, returning id + name + statement", async () => {
|
||||
const tools = auditorGraphTools({ chatStore, graphStore });
|
||||
const out = (await tools.find!.execute!("tc", { query: "*will to power*" })).details as string;
|
||||
assert.match(out, /1 match\(es\):/);
|
||||
assert.match(out, /- n1 :: Will to power/);
|
||||
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
|
||||
// Statements and aliases hit too — and a bare term gets implicit stars.
|
||||
const byStatement = (await tools.find!.execute!("tc", { query: "living again" })).details as string;
|
||||
assert.match(byStatement, /n2 :: Eternal recurrence/);
|
||||
const byAlias = (await tools.find!.execute!("tc", { query: "power drive" })).details as string;
|
||||
assert.match(byAlias, /n1/);
|
||||
const miss = (await tools.find!.execute!("tc", { query: "*nothing matches this*" })).details as string;
|
||||
assert.equal(miss, 'No existing proposition matches "*nothing matches this*".');
|
||||
});
|
||||
|
||||
it("list_sessions: names, kinds, counts, ids — NO timestamps (identity work needs content, not chronology)", async () => {
|
||||
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
|
||||
chats: [{ id: "c1", title: "Nietzsche seminar", createdAt: "2026-09-20T10:00:00.000Z", updatedAt: "2026-09-21T10:00:00.000Z" }],
|
||||
});
|
||||
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
|
||||
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
||||
assert.match(out, /1 session\(s\):/);
|
||||
assert.match(out, /Nietzsche seminar \(chat, 2 propositions\) — id: c1/);
|
||||
assert.ok(!out.includes("2026-09-20"), "no dates in the auditor's view");
|
||||
});
|
||||
|
||||
it("list_propositions: FULL statement text (identity work), no dates", async () => {
|
||||
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
|
||||
mappings: [
|
||||
{ chatId: "c1", sourceId: "p1", graphNodeId: "n1" },
|
||||
{ chatId: "c1", sourceId: "p2", graphNodeId: "n2" },
|
||||
],
|
||||
});
|
||||
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
|
||||
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||
assert.match(out, /2 proposition\(s\) born in this session:/);
|
||||
assert.match(out, /- n1 :: Will to power/);
|
||||
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
|
||||
assert.ok(!out.includes("2026-09"), "no dates in the auditor's view");
|
||||
const miss = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
|
||||
assert.equal(miss, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
||||
});
|
||||
});
|
||||
|
||||
+43
-132
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Graph merger + trigger + derivation tests (vision §1, 2026-09-02 redesign).
|
||||
* Graph merger + trigger + derivation tests (vision §1, 2026-09-02 redesign;
|
||||
* events cleanup 2026-09-27).
|
||||
* The merger (core/graph.ts mergeFindings) is the pipeline's deterministic
|
||||
* apply stage: AUDITED VERDICTS in, ledger appends out — link-or-create
|
||||
* identity (the auditor's linkTo, never string matching), deterministic quiz
|
||||
* events, understanding observations, and the idempotency guarantees.
|
||||
* identity (the auditor's linkTo, never string matching) and the
|
||||
* idempotency guarantees. No events ledger: quiz outcomes and
|
||||
* understanding observations are no longer formalized in the graph.
|
||||
*
|
||||
* Ledger isolation: PATHS is fixed at import time, so the five data/graph
|
||||
* ledgers are backed up and cleared before the suite and restored after
|
||||
@@ -30,15 +32,12 @@ import { LEDGER_FILES, PATHS, SOURCES_FILE } from "../config.ts";
|
||||
/** The wired fs adapters, exactly what src/server/stores.ts passes in production. */
|
||||
const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
||||
|
||||
/** The session merge source for a chat — what the pipeline passes: the live
|
||||
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
|
||||
* messages: null — see the notes-merger test below.) */
|
||||
function sessionSource(chatId: string): { messages: LhMessage[]; foldedAt: string } {
|
||||
// Wave B: the record is native history — the merger's transcript view
|
||||
// reads through the one-way projection (same shape the extraction
|
||||
// pipeline passes: its batch slots are projected messages).
|
||||
/** The session merge source for a chat — what the pipeline passes: the
|
||||
* fold stamp only (the merger reads no transcript; quiz-event rebuilding
|
||||
* died with the events ledger, 2026-09-27). */
|
||||
function sessionSource(chatId: string): { foldedAt: string } {
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
return { messages: record.messages, foldedAt: record.updatedAt };
|
||||
return { foldedAt: record.updatedAt };
|
||||
}
|
||||
|
||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||
@@ -215,9 +214,8 @@ function fixtureMessages(): LhMessage[] {
|
||||
].flat();
|
||||
}
|
||||
|
||||
/** The auditor's verdict for the fixture chat: three verified propositions
|
||||
* (no linkTo — the graph starts empty), one follows edge, all quizzes
|
||||
* attributed, one understanding note. */
|
||||
/** The auditor's verdict for the fixture chat: three propositions
|
||||
* (no linkTo — the graph starts empty), one follows edge. */
|
||||
function fixtureAudit(): AuditResult {
|
||||
return {
|
||||
propositions: [
|
||||
@@ -228,7 +226,6 @@ function fixtureAudit(): AuditResult {
|
||||
aliases: ["alpha feel"],
|
||||
messages: ["a1", "a3"],
|
||||
evidence: ["fixture evidence"],
|
||||
understanding: "solid",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
@@ -256,8 +253,6 @@ function fixtureAudit(): AuditResult {
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
],
|
||||
attribution: { quiz: { "call-1": "p1", "call-2": null, "call-3": "p1", "call-4": "p2", "call-5": "p3" } },
|
||||
dropped: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,14 +278,13 @@ function scopedGraph(chatIds: string[]) {
|
||||
return {
|
||||
nodes: graph.nodes.filter((n) => nodeIds.has(n.id)),
|
||||
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
|
||||
events: graph.events.filter((e) => chatIds.includes(e.chatId)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("the merger (mergeFindings)", () => {
|
||||
it("creates nodes from the verdicts, rebuilds quiz events deterministically, records observations", () => {
|
||||
it("creates nodes from the verdicts with message-granular provenance", () => {
|
||||
const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id));
|
||||
assert.deepEqual(stats, { nodes: 3, edges: 1, events: 6 });
|
||||
assert.deepEqual(stats, { nodes: 3, edges: 1 });
|
||||
|
||||
const graph = scopedGraph([chat1.id]);
|
||||
assert.equal(graph.nodes.length, 3);
|
||||
@@ -321,49 +315,14 @@ describe("the merger (mergeFindings)", () => {
|
||||
graphStore.readMappings().map((m) => m.sourceId),
|
||||
["p1", "p2", "p3"],
|
||||
);
|
||||
|
||||
// Events — kinds, refs, notes, and the attribution paths.
|
||||
assert.equal(graph.events.length, 6);
|
||||
const byRef = new Map(graph.events.map((e) => [e.ref, e]));
|
||||
|
||||
const miss = byRef.get("call-1")!;
|
||||
assert.equal(miss.kind, "miss");
|
||||
assert.equal(miss.nodeId, idByStatement.get("Alpha intuition"), "attributed quiz");
|
||||
|
||||
const dontKnow = byRef.get("call-2")!;
|
||||
assert.equal(dontKnow.kind, "dont-know");
|
||||
assert.equal(dontKnow.nodeId, null, "unattributable → null");
|
||||
assert.equal(dontKnow.question, "Q2 text");
|
||||
|
||||
const correct = byRef.get("call-3")!;
|
||||
assert.equal(correct.kind, "correct");
|
||||
assert.equal(correct.nodeId, idByStatement.get("Alpha intuition"), "attribution wins over the ignored input.nodeId");
|
||||
assert.equal(correct.note, "recalled the diagram");
|
||||
|
||||
const miss2 = byRef.get("call-4")!;
|
||||
assert.equal(miss2.kind, "miss");
|
||||
assert.equal(miss2.nodeId, idByStatement.get("Beta rule"));
|
||||
|
||||
const dontKnow2 = byRef.get("call-5")!;
|
||||
assert.equal(dontKnow2.kind, "dont-know");
|
||||
assert.equal(dontKnow2.nodeId, idByStatement.get("Gamma application"));
|
||||
|
||||
const observation = byRef.get("obs:p1")!;
|
||||
assert.equal(observation.kind, "observation");
|
||||
assert.equal(observation.nodeId, idByStatement.get("Alpha intuition"));
|
||||
assert.equal(observation.note, "solid");
|
||||
|
||||
// The dismissed quiz and the ask question became nothing.
|
||||
assert.equal([...byRef.keys()].filter((r) => r === "call-6" || r === "call-7").length, 0);
|
||||
});
|
||||
|
||||
it("is idempotent — re-merging the same verdicts appends nothing", () => {
|
||||
const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id));
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 });
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
||||
const graph = scopedGraph([chat1.id]);
|
||||
assert.equal(graph.nodes.length, 3);
|
||||
assert.equal(graph.edges.length, 1);
|
||||
assert.equal(graph.events.length, 6);
|
||||
// exactly one checkpoint line per merge, though — checkpoints are a log
|
||||
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === chat1.id).length, 2);
|
||||
});
|
||||
@@ -398,13 +357,11 @@ describe("the merger (mergeFindings)", () => {
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
attribution: { quiz: { "call-9": "q1" } },
|
||||
dropped: [],
|
||||
},
|
||||
graphDeps,
|
||||
sessionSource(chat2.id),
|
||||
);
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 1 });
|
||||
assert.deepEqual(stats, { nodes: 0, edges: 0 });
|
||||
|
||||
const after = scopedGraph([chat1.id, chat2.id]);
|
||||
assert.equal(after.nodes.length, 3, "no duplicate node");
|
||||
@@ -436,23 +393,18 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: [],
|
||||
messages: [],
|
||||
evidence: ["fixture evidence"],
|
||||
understanding: "solid",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [],
|
||||
},
|
||||
graphDeps,
|
||||
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
{ foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
);
|
||||
const snap = graphSnapshot(graphDeps);
|
||||
const node = snap.nodes.find((n) => n.statement === "Vim registers map single keys to editing commands.")!;
|
||||
assert.equal(snap.fileSources["src_snapfixture"], "/vault/learn vim.md");
|
||||
assert.deepEqual(snap.nodeSessions[node.id], ["src_snapfixture"], "the file entry joins the node's sessions list");
|
||||
assert.equal(snap.sessions["src_snapfixture"], undefined, "a notes source is never a chat session");
|
||||
// The understanding observation joins under the source id like any session's.
|
||||
assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid");
|
||||
});
|
||||
|
||||
it("derives BIRTH-SESSION clusters in graphSnapshot, labeled with the session's name", () => {
|
||||
@@ -478,11 +430,9 @@ describe("the merger (mergeFindings)", () => {
|
||||
{ from: "p2", to: "p3", type: "follows", messages: [], evidence: ["e"] },
|
||||
{ from: "p1", to: "p3", type: "follows", messages: [], evidence: ["e"] },
|
||||
],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [],
|
||||
},
|
||||
graphDeps,
|
||||
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
{ foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
);
|
||||
assert.equal(merge.nodes, 3);
|
||||
const snap = graphSnapshot(graphDeps);
|
||||
@@ -496,12 +446,14 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path");
|
||||
});
|
||||
|
||||
it("derives sessions and per-session understanding observations in graphSnapshot", () => {
|
||||
it("derives the sessions index in graphSnapshot", () => {
|
||||
const snap = graphSnapshot(graphDeps);
|
||||
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
|
||||
// No node-state classes anymore (2026-09-08): the snapshot carries
|
||||
// only the objective graph + sessions; quiz outcomes stay ledger data.
|
||||
// No node-state classes (2026-09-08) and no understanding
|
||||
// observations (2026-09-27 cleanup): the snapshot carries only the
|
||||
// objective graph + sessions.
|
||||
assert.equal("nodeStates" in snap, false);
|
||||
assert.equal("nodeObservations" in snap, false);
|
||||
|
||||
assert.equal(snap.sessions[chat1.id], "Graph fixture");
|
||||
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
|
||||
@@ -511,12 +463,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
);
|
||||
// Gamma was only ever touched by chat1.
|
||||
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]);
|
||||
|
||||
// Understanding observations: (node → chat → the extractor's note).
|
||||
// Alpha carries chat1's "solid" level; chat2's q1 has no level.
|
||||
const alphaObs = snap.nodeObservations[idByStatement.get("Alpha intuition")!] ?? {};
|
||||
assert.equal(alphaObs[chat1.id], "solid");
|
||||
assert.equal(alphaObs[chat2Id], undefined);
|
||||
});
|
||||
|
||||
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||
@@ -537,8 +483,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [],
|
||||
},
|
||||
graphDeps,
|
||||
sessionSource(chat3.id),
|
||||
@@ -547,10 +491,9 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.equal(getGraph(graphStore).nodes.filter((n) => n.statement === "Alpha intuition").length, 2);
|
||||
});
|
||||
|
||||
it("merges the notes front door: source id as chatId, no quiz events, checkpoint at import time", () => {
|
||||
// Raw personal notes carry no transcript — the merger gets no
|
||||
// message parts, so NO quiz events are rebuilt; the understanding
|
||||
// observation still lands, keyed to the source id.
|
||||
it("merges the notes front door: source id as chatId, checkpoint at import time", () => {
|
||||
// Raw personal notes carry no transcript — no message anchors; the
|
||||
// file is the provenance (and no events exist to build, 2026-09-27).
|
||||
const stats = mergeFindings(
|
||||
"src_notesfixture",
|
||||
{
|
||||
@@ -562,17 +505,14 @@ describe("the merger (mergeFindings)", () => {
|
||||
aliases: [],
|
||||
messages: [], // notes carry no message anchors — the file is the provenance
|
||||
evidence: ["fixture evidence"],
|
||||
understanding: "solid",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
attribution: { quiz: {} }, // normalized by the notes pipeline — nothing to attribute
|
||||
dropped: [],
|
||||
},
|
||||
graphDeps,
|
||||
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
{ foldedAt: "2026-09-13T00:00:00.000Z" },
|
||||
);
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
|
||||
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
|
||||
assert.deepEqual(node.provenance, [], "no message anchors — the mapping line carries the source");
|
||||
@@ -581,12 +521,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
sourceId: "p1",
|
||||
graphNodeId: node.id,
|
||||
});
|
||||
// The observation event is file-scoped; no quiz events exist.
|
||||
const events = graphStore.readEvents().filter((e) => e.chatId === "src_notesfixture");
|
||||
assert.deepEqual(
|
||||
events.map((e) => [e.kind, e.ref, e.note]),
|
||||
[["observation", "obs:p1", "solid"]],
|
||||
);
|
||||
// The checkpoint stamps the import time.
|
||||
assert.equal(graphStore.readCheckpoints().find((c) => c.chatId === "src_notesfixture")?.foldedAt, "2026-09-13T00:00:00.000Z");
|
||||
});
|
||||
@@ -673,12 +607,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
].flat());
|
||||
const auditA: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"] },
|
||||
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], messages: ["du1"], evidence: ["e"] },
|
||||
],
|
||||
edges: [{ from: "p1", to: "p2", type: "follows", messages: ["du1"], evidence: ["e"] }],
|
||||
attribution: { quiz: { "d-call-1": "p1" } },
|
||||
dropped: [],
|
||||
};
|
||||
mergeFindings(dA.id, auditA, graphDeps, sessionSource(dA.id));
|
||||
// Resolve through THIS session's mappings — the shared ledger holds
|
||||
@@ -701,12 +633,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
].flat());
|
||||
const auditB: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId },
|
||||
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], messages: ["eu1"], evidence: ["e"] },
|
||||
],
|
||||
edges: [{ from: "q1", to: "q2", type: "follows", messages: ["eu1"], evidence: ["e"] }],
|
||||
attribution: { quiz: { "e-call-1": "q1" } },
|
||||
dropped: [],
|
||||
};
|
||||
mergeFindings(dB.id, auditB, graphDeps, sessionSource(dB.id));
|
||||
const zId = graphStore.readMappings().find((m) => m.chatId === dB.id && m.sourceId === "q2")!.graphNodeId;
|
||||
@@ -730,7 +660,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 1,
|
||||
edgesRemoved: 1,
|
||||
eventsRemoved: 2,
|
||||
mappingsRemoved: 2,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
@@ -753,11 +682,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
// Edges: dA's assertion X→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: "eu1" }] }]);
|
||||
// Events: only dB's quiz + observation remain.
|
||||
assert.deepEqual(scoped.events.map((e) => [e.kind, e.chatId === dB.id]), [
|
||||
["miss", true],
|
||||
["observation", true],
|
||||
]);
|
||||
// Mappings: dA's audit trail died; dB's stays.
|
||||
const mappings = graphStore.readMappings().filter((m) => [dA.id, dB.id].includes(m.chatId));
|
||||
assert.deepEqual(mappings, [{ chatId: dB.id, sourceId: "q1", graphNodeId: xId }, { chatId: dB.id, sourceId: "q2", graphNodeId: zId }]);
|
||||
@@ -768,15 +692,16 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
// The snapshot resolves X to dB only — the deleted chat left no ghost.
|
||||
const snapshot = graphSnapshot(graphDeps);
|
||||
assert.deepEqual(snapshot.nodeSessions[xId], [dB.id]);
|
||||
assert.deepEqual(snapshot.nodeObservations[xId], { [dB.id]: "shaky" });
|
||||
assert.ok(snapshot.sessions[dB.id] && !snapshot.sessions[dA.id]);
|
||||
// Atomic rewrites left no temp files, and appends still work.
|
||||
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
|
||||
const eventsBefore = graphStore.readEvents().length;
|
||||
graphStore.appendEvent({ nodeId: null, kind: "correct", chatId: "chat_del_append_probe", at: "2026-09-23T00:04:00.000Z" });
|
||||
assert.equal(graphStore.readEvents().length, eventsBefore + 1);
|
||||
const nodesBefore = graphStore.readNodes().length;
|
||||
// Foreign provenance: the probe is another chat's data, so later
|
||||
// deletions must leave it untouched (the foreign-data doctrine).
|
||||
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, eventsRemoved: 0, mappingsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
||||
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, mappingsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
|
||||
});
|
||||
|
||||
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
|
||||
@@ -784,7 +709,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
const before = {
|
||||
nodes: graphStore.readNodes(),
|
||||
edges: graphStore.readEdges(),
|
||||
events: graphStore.readEvents(),
|
||||
mappings: graphStore.readMappings(),
|
||||
positions: graphStore.readPositions(),
|
||||
};
|
||||
@@ -792,14 +716,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.deepEqual(stats, {
|
||||
nodesRemoved: 0,
|
||||
edgesRemoved: 0,
|
||||
eventsRemoved: 0,
|
||||
mappingsRemoved: 0,
|
||||
coverageRemoved: 2,
|
||||
checkpointsRemoved: 1,
|
||||
} satisfies ChatDeletionStats);
|
||||
assert.deepEqual(graphStore.readNodes(), before.nodes);
|
||||
assert.deepEqual(graphStore.readEdges(), before.edges);
|
||||
assert.deepEqual(graphStore.readEvents(), before.events);
|
||||
assert.deepEqual(graphStore.readMappings(), before.mappings);
|
||||
assert.deepEqual(graphStore.readPositions(), before.positions);
|
||||
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
|
||||
@@ -853,7 +775,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
// 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: [], events: [], mappings: [] });
|
||||
graphStore.rewriteLedgers({ nodes: [], edges: [], mappings: [] });
|
||||
graphStore.writePositions({});
|
||||
graphStore.appendNode({
|
||||
id: "n_dead",
|
||||
@@ -884,10 +806,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
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" }] });
|
||||
// Events: two sessions' evidence on X, one on the neighbor.
|
||||
graphStore.appendEvent({ nodeId: "n_dead", kind: "correct", chatId: "chat_node_a", at: "2026-09-23T00:01:00.000Z", ref: "q1" });
|
||||
graphStore.appendEvent({ nodeId: "n_dead", kind: "observation", chatId: "chat_node_b", at: "2026-09-23T00:02:00.000Z", note: "shaky", ref: "obs:p1" });
|
||||
graphStore.appendEvent({ nodeId: "n_keep", kind: "miss", chatId: "chat_node_a", at: "2026-09-23T00:03:00.000Z", ref: "q2" });
|
||||
// Mappings: the notes anchor + the chat's audit trail.
|
||||
graphStore.appendMapping({ chatId: "src_nodefile", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
@@ -900,11 +818,9 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
buildNodeScenario();
|
||||
assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), {
|
||||
edges: 2,
|
||||
quizEvents: 1,
|
||||
observations: 1,
|
||||
// The file anchor, the re-encounter, and the second session's
|
||||
// observation — every referencing session, deduped and sorted.
|
||||
sessions: ["chat_node_a", "chat_node_b", "src_nodefile"],
|
||||
// The file anchor + the re-encounter — every referencing
|
||||
// session, deduped and sorted.
|
||||
sessions: ["chat_node_a", "src_nodefile"],
|
||||
});
|
||||
// Unknown node: null (the route's 404).
|
||||
assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null);
|
||||
@@ -914,7 +830,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
buildNodeScenario();
|
||||
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
||||
const stats = deleteNodeTraces(graphStore, "n_dead");
|
||||
assert.deepEqual(stats, { edgesRemoved: 2, eventsRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats);
|
||||
assert.deepEqual(stats, { edgesRemoved: 2, mappingsRemoved: 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
|
||||
@@ -930,11 +846,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
|
||||
graphStore.readEdges().map((e) => [e.from, e.to]),
|
||||
[["n_keep", "n_other"]],
|
||||
);
|
||||
// The evidence died with the thing it was about; the neighbor's stays.
|
||||
assert.deepEqual(
|
||||
graphStore.readEvents().map((e) => [e.nodeId, e.kind]),
|
||||
[["n_keep", "miss"]],
|
||||
);
|
||||
// The audit trail: X's mappings gone, the neighbor's stay.
|
||||
assert.deepEqual(graphStore.readMappings().map((m) => m.graphNodeId), ["n_keep"]);
|
||||
// The resting position died with the node.
|
||||
@@ -957,7 +868,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, eventsRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
|
||||
assert.deepEqual(stats, { edgesRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
|
||||
});
|
||||
|
||||
@@ -1007,7 +918,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, eventsRemoved: 0, mappingsRemoved: 0 });
|
||||
assert.deepEqual(outcome.stats, { edgesRemoved: 0, mappingsRemoved: 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);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { GraphEdge, GraphNode, LedgerEvent, NotesSource, ChatSummary } from "../src/core/contracts.ts";
|
||||
import type { GraphEdge, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts";
|
||||
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
|
||||
import {
|
||||
graphSessionIndex,
|
||||
@@ -36,7 +36,6 @@ const node = (id: string, name: string, provenance: Array<{ chatId: string; mess
|
||||
function fakeStores(
|
||||
nodes: GraphNode[],
|
||||
over: {
|
||||
events?: LedgerEvent[];
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
|
||||
@@ -46,7 +45,6 @@ function fakeStores(
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readEdges: () => edges,
|
||||
readEvents: () => over.events ?? [],
|
||||
readMappings: () => over.mappings ?? [],
|
||||
readCheckpoints: () => [],
|
||||
readCoverage: () => [],
|
||||
@@ -160,12 +158,8 @@ describe("the notes mapping-line fallback", () => {
|
||||
], "the file births under its src id; the childless chat has no entry (birth sessions only)");
|
||||
});
|
||||
|
||||
it("graphSessionPropositions lists the file's births under the src id", () => {
|
||||
const events: LedgerEvent[] = [
|
||||
{ nodeId: "a", kind: "observation", chatId: "src_abc", at: "2026-09-22T00:00:00.000Z", note: "solid", ref: "obs:p1" },
|
||||
];
|
||||
it("graphSessionPropositions lists the file's births under the src id (a pure birth list, 2026-09-27)", () => {
|
||||
const deps = fakeStores([node("a", "Alpha", []), node("b", "Beta", [])], {
|
||||
events,
|
||||
mappings: [
|
||||
{ chatId: "src_abc", sourceId: "p1", graphNodeId: "a" },
|
||||
{ chatId: "src_abc", sourceId: "p2", graphNodeId: "b" },
|
||||
@@ -173,8 +167,8 @@ describe("the notes mapping-line fallback", () => {
|
||||
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepEqual(graphSessionPropositions(deps, "src_abc"), [
|
||||
{ nodeId: "a", name: "Alpha", understanding: "solid", evaluatedAt: "2026-09-22T00:00:00.000Z" },
|
||||
{ nodeId: "b", name: "Beta", understanding: null, evaluatedAt: null },
|
||||
{ nodeId: "a", name: "Alpha" },
|
||||
{ nodeId: "b", name: "Beta" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -272,34 +266,16 @@ describe("graphSessionIndex (the prober's session list)", () => {
|
||||
});
|
||||
|
||||
describe("graphSessionPropositions (the per-session view)", () => {
|
||||
it("birth propositions with the latest extractor evaluation AND that event's own timestamp", () => {
|
||||
it("birth propositions — a pure list, no evaluations (2026-09-27 cleanup)", () => {
|
||||
const nodes = [
|
||||
node("a", "Alpha", [pt("chat1")]),
|
||||
node("b", "Beta", [pt("chat1")]),
|
||||
node("c", "Gamma", [pt("chat2")]), // born elsewhere — never listed under chat1
|
||||
];
|
||||
const events: LedgerEvent[] = [
|
||||
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "shaky", ref: "obs:p1" },
|
||||
// A LATER evaluation supersedes the earlier one for (a, chat1).
|
||||
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-21T00:00:00.000Z", note: "solid", ref: "obs:p2" },
|
||||
// Quiz evidence (owner ruling 2026-09-23): recorded in the ledger
|
||||
// but it NEVER moves the evaluation date.
|
||||
{ nodeId: "a", kind: "correct", chatId: "chat1", at: "2026-09-22T00:00:00.000Z", note: "got it" },
|
||||
// Another session's evaluation of the same node is not ours.
|
||||
{ nodeId: "a", kind: "observation", chatId: "chat2", at: "2026-09-23T00:00:00.000Z", note: "resisted" },
|
||||
// Same-at events: ledger order breaks the tie (the snapshot's rule).
|
||||
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "accepted", ref: "obs:p3" },
|
||||
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "not yet engaged", ref: "obs:p4" },
|
||||
];
|
||||
const deps = fakeStores(nodes, { events });
|
||||
const deps = fakeStores(nodes);
|
||||
assert.deepEqual(graphSessionPropositions(deps, "chat1"), [
|
||||
{ nodeId: "a", name: "Alpha", understanding: "solid", evaluatedAt: "2026-09-21T00:00:00.000Z" },
|
||||
{ nodeId: "b", name: "Beta", understanding: "not yet engaged", evaluatedAt: "2026-09-20T00:00:00.000Z" },
|
||||
]);
|
||||
// A node the session never evaluated shows nulls, never a guess.
|
||||
const fresh = fakeStores([node("x", "X", [pt("chat1")])], { events });
|
||||
assert.deepEqual(graphSessionPropositions(fresh, "chat1"), [
|
||||
{ nodeId: "x", name: "X", understanding: null, evaluatedAt: null },
|
||||
{ nodeId: "a", name: "Alpha" },
|
||||
{ nodeId: "b", name: "Beta" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The shared session-markdown engine (core/session-markdown.ts, owner ruling
|
||||
* 2026-09-27): ONE renderer for the extractor's source view and the future
|
||||
* user-facing session export — id-anchored speaker blocks, readable
|
||||
* quiz/recall/question blocks, one-line notes for other tools. Pinned here:
|
||||
* block shapes, anchor handling (on/off), title header, empty-message
|
||||
* skipping, and content parity with the coverage line builder.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { messageMarkdown, sessionMarkdown } from "../src/core/session-markdown.ts";
|
||||
import { messageViewLines } from "../src/core/coverage.ts";
|
||||
import { coverageSlots } from "../src/core/coverage.ts";
|
||||
|
||||
function user(id: string, text: string): LhMessage {
|
||||
return { id, role: "user", content: text, timestamp: Date.now() } as never;
|
||||
}
|
||||
|
||||
function teacher(id: string, text: string): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
function result(
|
||||
id: string,
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
details: unknown,
|
||||
isError = false,
|
||||
): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details,
|
||||
isError,
|
||||
timestamp: Date.now(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("messageMarkdown", () => {
|
||||
it("renders speaker blocks with the id anchor", () => {
|
||||
const md = messageMarkdown(user("u1", "teach me the chain rule"), "u1");
|
||||
assert.equal(md, "### Learner · u1\n\nteach me the chain rule");
|
||||
const md2 = messageMarkdown(teacher("a1", "The derivative measures change."), "a1");
|
||||
assert.equal(md2, "### Teacher · a1\n\nThe derivative measures change.");
|
||||
});
|
||||
|
||||
it("anchors off — the same document without the ids (the user export)", () => {
|
||||
const md = messageMarkdown(user("u1", "hello"), "u1", false);
|
||||
assert.equal(md, "### Learner\n\nhello");
|
||||
});
|
||||
|
||||
it("quiz blocks: question + outcome + the learner's note", () => {
|
||||
const md = messageMarkdown(
|
||||
result("r1", "q1", "quiz", { questionText: "What is a derivative?", correct: false, dontKnow: false, note: "mixed up signs" }),
|
||||
"r1",
|
||||
)!;
|
||||
assert.match(md, /^### Quiz · r1$/m);
|
||||
assert.match(md, /\*\*Q:\*\* What is a derivative\?/);
|
||||
assert.match(md, /The learner answered incorrectly\./);
|
||||
assert.match(md, /\*\(his thinking: mixed up signs\)\*/);
|
||||
});
|
||||
|
||||
it("explain results: the learner's own words are shown", () => {
|
||||
const md = messageMarkdown(
|
||||
result("r1", "e1", "explain", { questionText: "Why does Mercury stay near the Sun?", text: "it never strays far" }),
|
||||
"r1",
|
||||
)!;
|
||||
assert.match(md, /^### Recall check · r1$/m);
|
||||
assert.match(md, /The learner answered in his own words: it never strays far/);
|
||||
});
|
||||
|
||||
it("ask blocks: question + chosen options + free text", () => {
|
||||
const chosen = messageMarkdown(
|
||||
result("r1", "a1", "ask_user_question", { questionText: "Which way?", answers: ["second"], other: "also the third" }),
|
||||
"r1",
|
||||
)!;
|
||||
assert.match(chosen, /^### Question · r1$/m);
|
||||
assert.match(chosen, /The learner chose: second, also the third/);
|
||||
const free = messageMarkdown(
|
||||
result("r2", "a2", "ask_user_question", { questionText: "Why?", text: "because" }),
|
||||
"r2",
|
||||
)!;
|
||||
assert.match(free, /The learner answered: because/);
|
||||
});
|
||||
|
||||
it("other clean tool results: one line; failed calls and markers: nothing", () => {
|
||||
assert.equal(messageMarkdown(result("r1", "j1", "research", { brief: "long" }), "r1"), "### Tool · r1\n\n*research result delivered*");
|
||||
assert.equal(messageMarkdown(result("r2", "j2", "research", {}, true), "r2"), null, "a failed call is retry context");
|
||||
assert.equal(messageMarkdown(user("u3", " "), "u3"), null, "empty text renders nothing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionMarkdown", () => {
|
||||
it("assembles blocks in order under the title header, skipping nothings", () => {
|
||||
const md = sessionMarkdown([user("u1", "go"), teacher("a1", "First."), user("u2", " ")], { title: "My session" });
|
||||
assert.equal(md, "# My session\n\n### Learner · u1\n\ngo\n\n### Teacher · a1\n\nFirst.");
|
||||
});
|
||||
|
||||
it("no title → no header; anchors default on", () => {
|
||||
const md = sessionMarkdown([user("u1", "go")]);
|
||||
assert.equal(md, "### Learner · u1\n\ngo");
|
||||
});
|
||||
|
||||
it("content parity with the coverage line builder (2026-09-27 split)", () => {
|
||||
// Every message that produces view lines produces a markdown block,
|
||||
// and every message that renders nothing renders nothing in either —
|
||||
// same messages, two formats, one seam decision. The FINGERPRINT
|
||||
// stays on the line format (stable — no mass re-extraction when
|
||||
// presentation changes); markdown is presentation only.
|
||||
const messages: LhMessage[] = [
|
||||
user("u1", "teach me"),
|
||||
teacher("a1", "The chain rule multiplies local rates."),
|
||||
result("r1", "q1", "quiz", { questionText: "Q?", correct: true, dontKnow: false }),
|
||||
result("r2", "j1", "research", { brief: "b" }),
|
||||
result("r3", "j2", "research", {}, true),
|
||||
];
|
||||
for (const message of messages) {
|
||||
const hasLines = messageViewLines(message, message.id).length > 0;
|
||||
const hasMarkdown = messageMarkdown(message, message.id) !== null;
|
||||
assert.equal(hasMarkdown, hasLines, `parity for ${message.id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -243,7 +243,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, events: 3 },
|
||||
stats: { nodes: 1, edges: 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 })] },
|
||||
@@ -280,7 +280,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, events: 3 },
|
||||
stats: { nodes: 1, edges: 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