Files
learnharness/tests/graph-tools.test.ts
T
amosblomqvist 1b50013551 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.
2026-09-28 16:54:44 +03:00

236 lines
10 KiB
TypeScript

// 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 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 } from "../src/core/contracts/graph.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
const node = (
id: string,
name: string,
provenance: Array<{ chatId: string; messageId: string }>,
): GraphNode => ({
id,
name,
statement: `statement of ${name}`, // must NEVER leak into tool output
aliases: [],
createdAt: "2026-09-23T00:00:00.000Z",
provenance,
});
function fakeStores(
nodes: GraphNode[],
over: {
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,
readMappings: () => over.mappings ?? [],
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () => [],
readPositions: () => ({}),
writePositions: () => {},
readClusterLabels: () => [],
appendClusterLabel: () => {},
appendNamingRun: () => {},
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
getChat: (id: string) =>
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
} as unknown as ChatStore;
return { chatStore, graphStore };
}
const stubJobStore = {
createJob: (() => ({})) as never,
getJob: (() => undefined) as never,
listJobs: (() => []) as never,
updateJob: (() => {}) as never,
} as unknown as JobStore;
const baseCtx = {
chatId: "c1",
deliver: () => {},
jobStore: stubJobStore,
spawn: () => {},
};
// ─── registration (the factory gate) ────────────────────────────────────────
describe("prober graph tools — registration", () => {
it("no graphStore in the context → the tools are absent (gate decided server-side)", () => {
const { chatStore } = fakeStores([]);
const tools = createProberTools({ ...baseCtx, chatStore });
assert.equal("list_sessions" in tools, false);
assert.equal("list_propositions" in tools, false);
});
it("graphStore present → list_sessions + list_propositions join the registry beside the usual set", () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
// The standing set (fix wave 2026-09-20: research + pdf are
// phase-omnipresent) plus the two graph-map readers.
assert.deepEqual(Object.keys(tools).sort(), [
"ask_user_question",
"find",
"grep",
"list_propositions",
"list_sessions",
"ls",
"pdf",
"probe_done",
"quiz",
"read",
"research",
]);
});
});
// ─── output shape + the no-prose rule ──────────────────────────────────────
describe("list_sessions", () => {
it("names, kinds, counts, dates, ids — in the prober's voice, no statements", async () => {
const { chatStore, graphStore } = fakeStores(
[
node("n1", "Water cycle", [pt("c1")]),
node("n2", "Evaporation", [pt("c1")]),
node("n3", "Nietzsche", [pt("c-file")]),
],
{
chats: [
{
id: "c1",
title: "First lesson",
createdAt: "2026-09-20T10:00:00.000Z",
updatedAt: "2026-09-21T10:00:00.000Z",
},
],
},
);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
// The chat: name, kind, born count, dates, id — the full resume.
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
// The unrecorded birth session falls back honestly: the id, no fake date.
assert.match(out, /c-file \(file, 1 proposition, born unknown date\) — id: c-file/);
// The owner ruling: the map's prose never reaches the prober.
assert.ok(!out.includes("statement of"));
});
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
const { chatStore, graphStore } = fakeStores([]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
});
});
describe("list_propositions", () => {
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\) 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("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/);
assert.ok(!out.includes("never evaluated"));
});
it("an unknown session id — honest redirect, not a throw", 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: "no-such" })).details as string;
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.');
});
});