From 078635e61c95409ea4f8cada28a566581ae915cf Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 15:01:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(extraction):=20kill=20the=20dead=20LLM=20c?= =?UTF-8?q?luster-namer=20=E2=80=94=20session=20names=20own=20the=20labels?= =?UTF-8?q?=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 7 chunk A (950ddfd) replaced semantic clustering with birth-session clusters labeled by session names — the LLM namer became dead code, but extraction still called it, minting naming runs nobody reads on every merge. Real model spend wasted per extraction. Removed (agent band): - src/agent/subagents/cluster-namer.ts — the naming sub-agent, deleted whole (refreshClusterLabels, clusterNamingSettled, the naming loop). - extraction.ts: both post-merge garnish blocks (chat + notes paths), the labeler test seam on ExtractionModels, and the import. The labeler role's only consumer is gone; the 'labeler' entry in BACKGROUND_MODEL_ROLES/ROLE_SLOT (core band) is now consumer-less — flagged to Platform, not crossed. - scripts/import-notes.ts: the settle-before-exit line + the named- clusters count in the summary. - tests: the cluster-naming describe (naming + failure-non-fatal tests) and the dead CLUSTER_* fixtures; the labeler-less BYOK test repurposed as the kill pin — a merge mints NO naming runs and NO label records. NOT touched (flagged, outside the agent band): matchClusterLabelings (core/clusters.ts), clusterContentId (core/graph.ts — now consumer- less), the appendClusterLabel/readClusterLabels port seams + store impls + ClusterLabeling type (store/core band), tests/clusters.test.ts's matchClusterLabelings describe. Cost-summary's naming stage untouched per Platform's ruling (historical spend display reads readNamingRuns, which stays live). Gates: typecheck, 921/921, lint, build. --- scripts/import-notes.ts | 9 +- src/agent/subagents/cluster-namer.ts | 227 --------------------------- src/agent/subagents/extraction.ts | 25 --- tests/extraction.test.ts | 109 +------------ 4 files changed, 7 insertions(+), 363 deletions(-) delete mode 100644 src/agent/subagents/cluster-namer.ts diff --git a/scripts/import-notes.ts b/scripts/import-notes.ts index f1c7acf..490ddb0 100644 --- a/scripts/import-notes.ts +++ b/scripts/import-notes.ts @@ -16,7 +16,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts"; import { embedNewNodes } from "../src/server/embeddings.ts"; -import { clusterNamingSettled } from "../src/agent/subagents/cluster-namer.ts"; import { getGraph } from "../src/core/graph.ts"; import { stores } from "../src/server/stores.ts"; @@ -54,16 +53,10 @@ async function main() { }); await Promise.all(workers); - // The post-merge cluster naming is fire-and-forget inside the pipeline; - // settle it before the process exits so labels actually land. - await clusterNamingSettled(); - const graph = getGraph(stores.graphStore); - const labels = stores.graphStore.readClusterLabels().length; console.log( `[import-notes] done: +${totals.nodes} nodes, +${totals.edges} edges, +${totals.events} events, ${totals.failed} failed — ` + - `graph now ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${graph.events.length} events, ` + - `${labels} named clusters`, + `graph now ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${graph.events.length} events`, ); } diff --git a/src/agent/subagents/cluster-namer.ts b/src/agent/subagents/cluster-namer.ts deleted file mode 100644 index 1438185..0000000 --- a/src/agent/subagents/cluster-namer.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * CLUSTER NAMING (vision §1, DECIDED 2026-09-13): the garnish layer over - * community detection. Detection is purely structural and deterministic - * (core/clusters.ts); naming is ONE singular cheap LLM call that labels - * every NEW/CHANGED community at once, given member names + statements. - * - * Core-survival: a community keeps an existing labeling while at least - * half its core survives in it (matchClusterLabelings); only communities - * with no surviving label AND at least MIN_CLUSTER_SIZE members are sent - * to the namer. Records are append-only and idempotent (content-hashed - * ids dedupe at the store seam), so a naming race or a re-run appends - * nothing new. - * - * Runs POST-EXTRACTION (user-decided 2026-09-13): both front doors call - * refreshClusterLabels after a successful merge. Failure is NON-FATAL — - * the graph and hulls render fine unlabeled; the next run retries. - */ -import { z } from "zod"; -import { lhTool, toPiTools } from "../lh-tool.ts"; -import type { PiEngine } from "../models.ts"; -import { runSubagent } from "./pi-run.ts"; -import type { CallUsage, GraphNode, NamingRunUsage } from "../../core/contracts.ts"; -import { callUsage } from "./extraction.ts"; -import { getGraph, clusterContentId } from "../../core/graph.ts"; -import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../../core/clusters.ts"; -import type { GraphStore } from "../../core/ports.ts"; - -/** How many member propositions the namer sees per cluster — enough to - * recognize the domain without paying for the whole region. */ -const MAX_MEMBERS_PER_CLUSTER = 12; - -const CLUSTER_NAMER_INSTRUCTIONS = `You name communities of propositions in a personal knowledge graph. - -You are given clusters. Each cluster is a set of related propositions -(theory fragments the author is learning) plus, for context, a sketch of -every OTHER cluster already named. For each UNNAMED cluster, mint ONE -short DOMAIN LABEL: 1-4 words naming the region of the world model those -propositions belong to together — like a section heading in a textbook. -Use the members' own vocabulary ("Differential forms", "Sales -psychology", "Server architecture"), not generic words ("Topics", -"Various concepts"). If the propositions are unrelated, that is fine: -name the best common denominator anyway — a reader should recognize the -region at a glance. - -Your entire deliverable is ONE submit_labels call. Include EVERY -top-level field even when empty (labels: [] when nothing to name).`; - -const labelsSchema = z.object({ - labels: z - .array( - z.object({ - /** The cluster key from the prompt ("c1", "c2", ...). */ - key: z.string(), - label: z.string(), - }), - ) - .default([]), -}); - -export interface UnnamedCluster { - key: string; - members: string[]; - nodes: Pick[]; -} - -/** One single-submit namer run (the same shape as every extraction stage): - * the whole deliverable is ONE zod-validated submit call. */ -async function nameClusters( - engine: PiEngine | undefined, - signal: AbortSignal | undefined, - unnamed: UnnamedCluster[], - onStep?: (usage: CallUsage, model: string) => void, -): Promise<{ key: string; label: string }[]> { - let submitted: { key: string; label: string }[] | undefined; - await runSubagent( - toPiTools({ - submit_labels: lhTool({ - name: "submit_labels", - description: "Submit the cluster labels.", - schema: labelsSchema, - execute: async (labels) => { - submitted = labels.labels; - return labels; - }, - }), - }), - { - role: "labeler", - engine, - systemPrompt: CLUSTER_NAMER_INSTRUCTIONS, - prompt: JSON.stringify( - { - unnamedClusters: unnamed.map((c) => ({ - key: c.key, - members: c.nodes.map((n) => ({ name: n.name, statement: n.statement })), - })), - }, - null, - 2, - ), - maxTurns: 4, - abortSignal: signal, - onAssistantMessage: (message) => onStep?.(callUsage(message.usage), message.model), - shouldStop: (_message, toolResults) => - toolResults.some((r) => r.toolName === "submit_labels" && !r.isError), - }, - ); - if (!submitted) throw new Error("the cluster namer never submitted labels"); - return submitted; -} - -export interface ClusterRefreshStats { - /** Communities the current graph divides into (all sizes). */ - detected: number; - /** Communities that kept a label via core-survival. */ - claimed: number; - /** Communities sent to the namer (unnamed, size >= MIN_CLUSTER_SIZE). */ - named: number; - /** Labeling records actually appended (idempotent dedupe may shrink this). */ - appended: number; -} - -/** - * The post-extraction garnish: detect communities on the CURRENT graph, - * join persisted labelings (core-survival), and name what is new — one - * singular LLM call. Appends ClusterLabeling records; the NEXT snapshot - * read joins them (the payload is derived, never stored). - * - * Serialized: concurrent merges (parallel notes imports) chain here, so - * two runs never mint duplicate labels for the same community. - */ -async function doRefresh(deps: { - graphStore: GraphStore; - /** Test seam: a scripted engine (production uses the labeler role). */ - engine?: PiEngine; - signal?: AbortSignal; -}): Promise { - const { graphStore, engine, signal } = deps; - const { nodes } = getGraph(graphStore); - // Semantic topics: the partition the embedder persisted in the - // embedding ledger (same source the snapshot renders — never - // recomputed here; the agglomerative pass is ~1.5s at scale). - const communities = graphStore.readEmbeddings().clusters; - const labelings = graphStore.readClusterLabels(); - const claims = matchClusterLabelings(communities, labelings); - const byId = new Map(nodes.map((n) => [n.id, n])); - - const claimed = claims.filter((c) => c !== null).length; - const unnamed = communities - .filter((c, i) => !claims[i] && c.members.length >= MIN_CLUSTER_SIZE) - .map((c: Community, i) => { - const nodesFor = c.members - .map((id) => byId.get(id)) - .filter((n): n is GraphNode => n !== undefined) - .slice(0, MAX_MEMBERS_PER_CLUSTER) - .map((n) => ({ name: n.name, statement: n.statement })); - return { key: `c${i}`, members: c.members, nodes: nodesFor }; - }); - - if (unnamed.length === 0) return { detected: communities.length, claimed, named: 0, appended: 0 }; - - // Money channel (2026-09 graph cost metrics): the naming call is the - // pipeline's third billable stage — capture per-step usage + model as - // they bill, then persist ONE record for the run, success or failure - // (money spent either way, same discipline as extraction runs). - const startedAt = Date.now(); - const t0 = performance.now(); - const steps: NamingRunUsage["steps"] = []; - let model = "unknown"; - let appended = 0; - try { - const labels = await nameClusters(engine, signal, unnamed, (usage, modelId) => { - steps.push(usage); - model = modelId; - }); - const seen = new Set(); - for (const { key, members } of unnamed) { - if (seen.has(key)) continue; - const label = labels.find((l) => l.key === key)?.label?.trim(); - if (!label) continue; - seen.add(key); - graphStore.appendClusterLabel({ - id: clusterContentId(members), - label, - core: members, - createdAt: new Date().toISOString(), - }); - appended++; - } - return { detected: communities.length, claimed, named: unnamed.length, appended }; - } finally { - // The finally (not just success) keeps failed/aborted runs billed - // too — zero appended, steps as far as they got. Runs whose model - // never billed a step (aborted before the first message) still - // record: the idempotency key is this run's startedAt. - graphStore.appendNamingUsage({ - startedAt, - at: new Date().toISOString(), - durationMs: Math.round(performance.now() - t0), - model, - steps, - stats: { detected: communities.length, claimed, named: unnamed.length, appended }, - }); - } -} - -let namingTail: Promise = Promise.resolve(); - -/** - * Fire the refresh, serialized behind any refresh already running (a - * parallel import's merge waits its turn; each run reads fresh ledgers). - * Waivable: call sites may fire-and-forget; scripts await the return. - */ -export function refreshClusterLabels(deps: { - graphStore: GraphStore; - engine?: PiEngine; - signal?: AbortSignal; -}): Promise { - const run = namingTail.then(() => doRefresh(deps)); - namingTail = run.catch(() => {}); - return run; -} - -/** Await every refresh fired so far (scripts: don't exit mid-naming). */ -export async function clusterNamingSettled(): Promise { - await namingTail; -} diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 745bdd1..dff0527 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -21,7 +21,6 @@ import type { GraphStore } from "../../core/ports.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/coverage.ts"; import { EXTRACTION } from "../../../config.ts"; -import { refreshClusterLabels } from "./cluster-namer.ts"; /** * The extraction pipeline (vision §1 construction, 2026-09-02 redesign) — the @@ -567,8 +566,6 @@ export interface ExtractionModels { * engineFor chain (production wires no direct values). */ extractor?: PiEngine; auditor?: PiEngine; - /** Test seam for the cluster namer (post-extraction garnish). */ - labeler?: PiEngine; /** Settings-resolved engine per role — the same slot treatment the * job runner got, read FRESH per stage call (a settings PUT between * stages lands). BYOK (owner, 2026-09-22): the chain (role override → @@ -880,18 +877,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti `[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` + `(${Math.round(performance.now() - t0)}ms total)`, ); - // Post-extraction garnish (DECIDED 2026-09-13): name new/changed - // clusters. Fire-and-forget — naming never fails the run. - const labelerEngine = stageEngine("labeler", deps.labeler); - if (labelerEngine) { - refreshClusterLabels({ - graphStore, - engine: labelerEngine, - signal: runningExtractions.get(chatId)?.controller.signal, - }).catch(() => {}); - } else { - console.info(`[extraction ${chatId}] cluster naming skipped — no labeler model configured`); - } return stats; } catch (err) { persistRunUsage(chatId, Math.round(performance.now() - t0), batchesDone > 0 ? total : undefined); @@ -1010,16 +995,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti `[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` + `(${Math.round(performance.now() - t0)}ms total)`, ); - const labelerEngine = stageEngine("labeler", deps.labeler); - if (labelerEngine) { - refreshClusterLabels({ - graphStore, - engine: labelerEngine, - signal: runningExtractions.get(id)?.controller.signal, - }).catch(() => {}); - } else { - console.info(`[extraction ${id}] cluster naming skipped — no labeler model configured`); - } return stats; } catch (err) { persistRunUsage(id, Math.round(performance.now() - t0), undefined); diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 1fe3192..05a29c3 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -43,7 +43,6 @@ import type { GraphStore } from "../src/core/ports.ts"; import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts"; import { ModelsNotConfiguredError } from "../src/core/settings.ts"; import { batchTag, coverageOf, coverageSlots } from "../src/core/coverage.ts"; -import { clusterNamingSettled, refreshClusterLabels } from "../src/agent/subagents/cluster-namer.ts"; import { semanticClusters } from "../src/core/semantic-clusters.ts"; import { chatStore } from "../src/store/chats.ts"; import { PATHS } from "../config.ts"; @@ -365,7 +364,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", verdict), - labeler: submitModel("submit_labels", { labels: [] }), }); const stats = await pipeline.runExtraction(chatId); @@ -428,7 +426,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { assert.equal(ledgers.readCoverage().length, 0); }); - it("a labeler-less run merges fine and SKIPS cluster naming honestly (BYOK, 2026-09-22)", async () => { + 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). @@ -444,11 +442,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", verdict), - // NO labeler: the cluster-naming garnish skips (console line), - // the merge itself is unaffected. }); const stats = await pipeline.runExtraction(chatId); assert.deepEqual(stats, { nodes: 1, edges: 0, events: 3 }); + // 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. + assert.equal(ledgers.readNamingRuns().length, 0, "no naming runs minted"); + assert.equal(ledgers.readClusterLabels().length, 0, "no label records appended"); }); @@ -501,7 +502,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", verdict), - labeler: submitModel("submit_labels", { labels: [] }), // If the direct values did NOT win, this throw would surface. engineFor: (role) => { throw new Error(`engineFor should not be consulted for ${role} when a direct value is set`); @@ -521,7 +521,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: silentModel(), // never submits the review - labeler: submitModel("submit_labels", { labels: [] }), }); await assert.rejects(pipeline.runExtraction(chatId), /the auditor never submitted a review/); assert.equal(ledgers.readNodes().length, 0); @@ -533,7 +532,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: silentModel(), auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }), - labeler: submitModel("submit_labels", { labels: [] }), }); await assert.rejects(failingExtractor.runExtraction(chatId), /the extractor never submitted findings/); assert.equal(ledgers.readCheckpoints().length, 0); @@ -567,7 +565,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { // registry must already carry this chat at the right phase. extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())), auditor: submitModel("submit_review", verdict, () => seen.push(phaseOf())), - labeler: submitModel("submit_labels", { labels: [] }), }); await pipeline.runExtraction(chatId); assert.deepEqual(seen, ["extracting", "auditing"]); @@ -596,7 +593,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: gatedEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", verdict), - labeler: submitModel("submit_labels", { labels: [] }), }); const run = pipeline.runExtraction(chatId); // Wait for the tail: the phase must flip to "embedding" while the @@ -646,7 +642,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { auditor: submitModel("submit_review", verdict, () => usageSeen.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage.extractor?.steps.at(-1)?.inputTokens), ), - labeler: submitModel("submit_labels", { labels: [] }), }); await pipeline.runExtraction(chatId); @@ -685,7 +680,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: hangingModel(), // hangs until aborted — no step finishes auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }), - labeler: submitModel("submit_labels", { labels: [] }), }); const runPromise = pipeline.runExtraction(chatId); @@ -737,7 +731,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: gatedExtractor, auditor: silentModel(), - labeler: submitModel("submit_labels", { labels: [] }), }); const first = pipeline.runExtraction(chatId); @@ -772,7 +765,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: gatedExtractor, auditor: silentModel(), - labeler: submitModel("submit_labels", { labels: [] }), }); // A SECOND instance over DIFFERENT ledgers — the observer a separate // route entry's module instance is in dev (the turn-liveness lesson: @@ -783,7 +775,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: silentModel(), auditor: silentModel(), - labeler: submitModel("submit_labels", { labels: [] }), }); const first = pipeline.runExtraction(chatId); @@ -811,7 +802,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { embedder: fakeEmbedder, extractor: silentModel(), auditor: silentModel(), - labeler: submitModel("submit_labels", { labels: [] }), }); await assert.rejects(pipeline.runExtraction("chat_does_not_exist"), /Chat not found/); }); @@ -849,24 +839,6 @@ const NOTES_VERDICT = { dropped: [], } as const; -const CLUSTER_FINDINGS = { - propositions: [ - { id: "p1", name: "Modal editing", statement: "Vim edits text through modal command modes.", aliases: [], messages: [], evidence: ["Normal mode keys are commands."], understanding: "solid" }, - { id: "p2", name: "Verb-object grammar", statement: "Vim commands compose as verb and object.", aliases: [], messages: [], evidence: ["d then motion deletes."], understanding: "solid" }, - { id: "p3", name: "Operator-pending", statement: "Vim enters operator-pending mode between verb and object.", aliases: [], messages: [], evidence: ["After d, a motion is expected."], understanding: "solid" }, - ], - edges: [ - { from: "p1", to: "p2", type: "follows", messages: [], evidence: ["e"] }, - { from: "p2", to: "p3", type: "follows", messages: [], evidence: ["e"] }, - ], -} as const; - -const CLUSTER_VERDICT = { - propositions: CLUSTER_FINDINGS.propositions, - edges: CLUSTER_FINDINGS.edges, - dropped: [], -} as const; - /** Test seam for the embedder: fakes the post-merge embedding batch — * one identical unit vector per node → semanticClusters merges them all * into ONE topic — then persists the partition like the real embedder. */ @@ -888,66 +860,6 @@ const fakeEmbedder = async (store: GraphStore) => { }); }; -describe("cluster naming (the post-extraction garnish)", () => { - it("names a new community after a notes merge — one record, claimed on the next refresh", async () => { - const ledgers = memoryLedgers(); - const namerCalls: number[] = []; - const labeler = submitModel("submit_labels", { labels: [{ key: "c0", label: "Vim modal grammar" }] }, () => namerCalls.push(1)); - const pipeline = createExtractionPipeline({ - chatStore, - graphStore: ledgers, - embedder: fakeEmbedder, - extractor: submitModel("submit_findings", CLUSTER_FINDINGS), - auditor: submitModel("submit_review", CLUSTER_VERDICT), - labeler, - }); - const stats = await pipeline.importNotes({ filePath: "/vault/modal.md", text: "raw notes, any format" }); - assert.deepEqual(stats, { nodes: 3, edges: 2, events: 3 }); - // The refresh is fire-and-forget inside the pipeline — settle it. - await clusterNamingSettled(); - assert.equal(namerCalls.length, 1, "ONE singular naming call"); - const records = ledgers.readClusterLabels(); - assert.equal(records.length, 1); - assert.equal(records[0]!.label, "Vim modal grammar"); - assert.equal(records[0]!.core.length, 3); - assert.match(records[0]!.id, /^clu_/); - // Second refresh: core-survival claims it — nothing new to name. - const again = await refreshClusterLabels({ graphStore: ledgers, engine: labeler }); - assert.equal(again.named, 0); - assert.equal(again.appended, 0); - assert.ok(again.claimed >= 1); - assert.equal(ledgers.readClusterLabels().length, 1, "no duplicate records"); - // Money channel (2026-09 cost metrics): the naming call billed — - // ONE run record with the faux model, deterministic > 0 usage, and - // the refresh stats. The second refresh named nothing (no model - // call — early return), so no second record. - const namingRuns = ledgers.readNamingRuns(); - assert.equal(namingRuns.length, 1, "one naming run billed"); - assert.equal(namingRuns[0]!.model, labeler.model.id); - assert.ok(namingRuns[0]!.steps.length >= 1, "at least one billed step"); - assert.ok((namingRuns[0]!.steps[0]!.inputTokens ?? 0) > 0, "usage estimated > 0"); - assert.equal(namingRuns[0]!.stats.appended, 1); - assert.equal(namingRuns[0]!.stats.named, 1); - assert.ok(namingRuns[0]!.durationMs >= 0); - }); - - it("naming failure never fails the run", async () => { - const ledgers = memoryLedgers(); - const pipeline = createExtractionPipeline({ - chatStore, - graphStore: ledgers, - embedder: fakeEmbedder, - extractor: submitModel("submit_findings", CLUSTER_FINDINGS), - auditor: submitModel("submit_review", CLUSTER_VERDICT), - labeler: silentModel(), // never submits — the namer throws - }); - const stats = await pipeline.importNotes({ filePath: "/vault/modal.md", text: "raw notes" }); - assert.deepEqual(stats, { nodes: 3, edges: 2, events: 3 }, "the merge itself succeeded"); - await clusterNamingSettled(); - assert.equal(ledgers.readClusterLabels().length, 0, "nothing named, nothing appended"); - }); -}); - describe("the notes front door (importNotes — format-agnostic)", () => { it("imports raw notes end to end: source record, file-scoped provenance, no quiz events", async () => { const ledgers = memoryLedgers(); @@ -962,7 +874,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => { const run = extractionStatuses().find((r) => r.chatId.startsWith("src_")); seen.push({ kind: run?.kind, label: run?.label }); }), - labeler: submitModel("submit_labels", { labels: [] }), }); const stats = await pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); @@ -1007,7 +918,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", NOTES_FINDINGS), auditor: submitModel("submit_review", NOTES_VERDICT), - labeler: submitModel("submit_labels", { labels: [] }), }); const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); assert.deepEqual(first, { nodes: 1, edges: 0, events: 1 }); @@ -1028,7 +938,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => { embedder: fakeEmbedder, extractor: hangingModel(), auditor: submitModel("submit_review", NOTES_VERDICT), - labeler: submitModel("submit_labels", { labels: [] }), }); const runPromise = pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); @@ -1111,7 +1020,6 @@ describe("the chunked pipeline (extraction coverage)", () => { embedder: fakeEmbedder, extractor: fauxEngine([findingsStep((v) => views.push(v)), findingsStep((v) => views.push(v))]), auditor: fauxEngine([passThroughAuditStep(), passThroughAuditStep()]), - labeler: submitModel("submit_labels", { labels: [] }), }); const stats = await pipeline.runExtraction(chatId); @@ -1167,7 +1075,6 @@ describe("the chunked pipeline (extraction coverage)", () => { }), ]), auditor: fauxEngine([passThroughAuditStep()]), - labeler: submitModel("submit_labels", { labels: [] }), }); const runPromise = pipeline.runExtraction(chatId); // The abort must land while batch 2's extractor hangs. The faux @@ -1199,7 +1106,6 @@ describe("the chunked pipeline (extraction coverage)", () => { embedder: fakeEmbedder, extractor: fauxEngine([findingsStep((v) => reRunViews.push(v))]), auditor: fauxEngine([passThroughAuditStep()]), - labeler: submitModel("submit_labels", { labels: [] }), }); await finisher.runExtraction(chatId); assert.equal(reRunViews.length, 1); @@ -1222,7 +1128,6 @@ describe("the chunked pipeline (extraction coverage)", () => { embedder: fakeEmbedder, extractor: fauxEngine([findingsStep()]), auditor: fauxEngine([passThroughAuditStep()]), - labeler: submitModel("submit_labels", { labels: [] }), }); await pipeline.runExtraction(chatId); const messages = () => chatStore.getChat(chatId)!.messages as UIMessage[]; @@ -1255,7 +1160,6 @@ describe("the chunked pipeline (extraction coverage)", () => { embedder: fakeEmbedder, extractor: fauxEngine([findingsStep((v) => reRunViews.push(v))]), auditor: fauxEngine([passThroughAuditStep()]), - labeler: submitModel("submit_labels", { labels: [] }), }); await reOpener.runExtraction(chatId); assert.equal(reRunViews.length, 1, "one batch: the single re-opened message"); @@ -1279,7 +1183,6 @@ describe("the session-deletion guard (chat deleted mid-run)", () => { embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", { propositions: FINDINGS.propositions, edges: [], attribution: { quiz: {} }, dropped: [] }, () => chatStore.deleteChat(chatId)), - labeler: submitModel("submit_labels", { labels: [] }), }); const stats = await pipeline.runExtraction(chatId);