feat(extraction): kill the dead LLM cluster-namer — session names own the labels now
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.
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GraphNode, "name" | "statement">[];
|
||||
}
|
||||
|
||||
/** 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<ClusterRefreshStats> {
|
||||
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<string>();
|
||||
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<unknown> = 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<ClusterRefreshStats> {
|
||||
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<void> {
|
||||
await namingTail;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+6
-103
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user