Cost channel cull: the cluster-naming fold dies — two stages, two channels

Off UI's d651f93 flag (displayCostTotals already isolates the display to
extraction + audit; the naming stage row and naming runs were filtered
out client-side). This culls the server side of the same ruling.

THE STANDING RULING, REVERSED BY ITS OWN PREMISE: NamingRunUsage /
readNamingRuns / appendNamingUsage were kept at the wave-7 namer kill
ONLY because cost-summary's HISTORICAL naming stage read them. With the
fold culled, that premise dies — so the whole ledger read path goes too,
following the wave-7 embeddings precedent chain: consumer-less ledger
seams are deleted, and the on-disk naming-runs.jsonl stays INERT (real
trees keep their historical spend bytes; they are simply not displayed
anywhere — the same treatment as historical stages.embedding spend).

- core/cost-summary.ts: two channels in (chat + notes front doors), two
  stages out; RecentRun.kind collapses to "extraction" (labels field
  gone); CostSummaryInput.naming gone; totalDollars = extraction + audit.
- ports/store/contracts/config: appendNamingUsage + readNamingRuns ports,
  store impls + guards, the NamingRunUsage interface, and
  LEDGER_FILES.namingRuns die. graphRevision no longer counts the file
  (a smaller revision value is harmless — reads never compare).
- components/graph-view.tsx (mechanical, flagged): the defensive
  .filter(run.kind !== "naming") guards a kind that no longer exists.
- Tests: cost-summary's both-channels fold pins; the naming fixtures and
  the appendNamingUsage store pin die with the seams.
This commit is contained in:
amosblomqvist
2026-09-24 19:33:35 +03:00
parent 2cba283009
commit 73027f49bf
12 changed files with 33 additions and 153 deletions
+3 -3
View File
@@ -298,6 +298,8 @@ function fmtRunCost(dollars: number | null): string {
* titles / file names — so the core's naming fold (and its historical
* spend) is deliberately not shown anywhere: the pill and the panel must
* agree, and both read THIS, never GraphCostSummary.totalDollars.
* (The core fold's naming stage was then culled outright — 2026-09-24 —
* so the recent-runs list carries extraction runs only.)
*/
export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
totalDollars: number;
@@ -393,9 +395,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
))}
</div>
<div className="mt-2 flex flex-col gap-0.5 border-t pt-2">
{costs.runs
.filter((run) => run.kind !== "naming")
.map((run) => (
{costs.runs.map((run) => (
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="min-w-0 flex-1 truncate text-muted-foreground">
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
-3
View File
@@ -73,9 +73,6 @@ export const LEDGER_FILES = {
* the checkpoint's replacement for chat extractions — notes keep
* sessions.jsonl, whole-file as they are). */
coverage: path.join(PATHS.graph, "coverage.jsonl"),
/** Cluster-naming run usage (contracts/graph.ts NamingRunUsage) — the
* money channel's third stage; graph-global, so its own ledger. */
namingRuns: path.join(PATHS.graph, "naming-runs.jsonl"),
} as const;
/**
-21
View File
@@ -79,27 +79,6 @@ export interface NamingRunStats {
appended: number;
}
/** One cluster-naming run's usage record — the money channel's THIRD stage
* (graph cost metrics, 2026-09). The namer is graph-global (tied to no
* chat or source), so runs live in their own append-only ledger
* (config.LEDGER_FILES.namingRuns). Appended when a run actually called
* the model; skipped refreshes (nothing to name) never bill and never
* append. Idempotent per startedAt — same discipline as every usage
* channel. Cost is INFERRED at read time from steps + model
* (core/prices.ts) — dollars are never stored. */
export interface NamingRunUsage {
/** ms epoch — the idempotency key. */
startedAt: number;
/** ISO — wall-clock time of the run. */
at: string;
durationMs: number;
/** The naming run's labeler role model id (the role died in wave 7;
* the field stays as the historical record's shape). */
model: string;
/** Every billed call (usually ONE — the namer is single-submit). */
steps: CallUsage[];
stats: NamingRunStats;
}
export interface GraphNode {
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
+14 -35
View File
@@ -1,10 +1,12 @@
/**
* Graph-pipeline cost summary (2026-09 graph cost metrics wave) — the pure
* fold behind the graph view's cost pill. Three usage channels in: chat
* extraction runs (ChatRecord.extractions), notes-source runs
* (NotesSource.extractions), and cluster-naming runs (naming-runs.jsonl).
* Out: per-stage totals (extraction / audit / cluster-naming) + a capped
* recent-runs list.
* fold behind the graph view's cost pill. Two usage channels in: chat
* extraction runs (ChatRecord.extractions) and notes-source runs
* (NotesSource.extractions). Out: per-stage totals (extraction / audit)
* + a capped recent-runs list. (The cluster-naming channel died with the
* extraction-UX close-out, 2026-09-24 — UI's displayCostTotals had already
* dropped it; historical naming-runs.jsonl spend is simply not displayed,
* the same ruling as wave 7's stages.embedding.)
*
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
* core/prices.ts — never stored anywhere. An unpriced model contributes
@@ -15,7 +17,7 @@
* barrel so the pill/panel components carry no cost math.
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage, NamingRunUsage } from "./contracts.ts";
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { callCost, sumSteps } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
@@ -37,7 +39,7 @@ export interface RecentRun {
at: string;
durationMs: number;
/** "extraction" covers both front doors (chat + notes). */
kind: "extraction" | "naming";
kind: "extraction";
/** Chat title / source file path / fixed label for naming runs. */
title: string;
/** Whole-run inferred USD; null only when EVERY stage is unpriced. */
@@ -45,8 +47,6 @@ export interface RecentRun {
tokens: CallUsage;
/** What the run merged into the graph; absent on failed runs. */
merged?: EmissionStats;
/** Naming runs: labeling records appended (extraction runs omit it). */
labels?: number;
}
/** The cost pill's whole payload: stage totals + recent runs. */
@@ -56,13 +56,12 @@ export interface GraphCostSummary {
/** Runs where EVERY stage was unpriced (totalDollars excludes them). */
unpricedRuns: number;
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
* back to three — the embedding stage is gone with the provider
* embedder; historical records' stages.embedding spend is simply not
* displayed.) */
* the embedding stage is gone with the provider embedder; the
* 2026-09-24 close-out culled cluster naming the same way —
* historical spend on either is simply not displayed.) */
stages: {
extraction: StageCost;
audit: StageCost;
naming: StageCost;
};
/** Newest first, capped at `recentCap` (default 10). */
runs: RecentRun[];
@@ -74,8 +73,6 @@ export interface CostSummaryInput {
chats: { title: string; extractions: ExtractionRunUsage[] }[];
/** Notes-front-door runs: imported file path + the source's usage log. */
sources: { filePath: string; extractions: ExtractionRunUsage[] }[];
/** Cluster-naming runs (graph-global ledger). */
naming: NamingRunUsage[];
recentCap?: number;
}
@@ -116,7 +113,6 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const extraction = emptyStage();
const audit = emptyStage();
const naming = emptyStage();
const runs: RecentRun[] = [];
const foldExtractionRun = (run: ExtractionRunUsage, title: string) => {
@@ -158,23 +154,6 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
for (const source of input.sources) {
for (const run of source.extractions) foldExtractionRun(run, source.filePath);
}
for (const run of input.naming) {
naming.runs++;
mergeTokens(naming.tokens, run.steps);
const dollars = stageDollars(run.model, run.steps);
if (dollars === null) naming.unpricedRuns++;
else naming.dollars += dollars;
runs.push({
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
kind: "naming",
title: "Cluster naming",
dollars,
tokens: sumSteps(run.steps),
labels: run.stats.appended,
});
}
runs.sort((a, b) => b.startedAt - a.startedAt);
const capped = runs.slice(0, input.recentCap ?? 10);
@@ -182,9 +161,9 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
// A run is "unpriced" when it billed nothing countable at all.
const unpricedRuns = runs.filter((r) => r.dollars === null).length;
return {
totalDollars: extraction.dollars + audit.dollars + naming.dollars,
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit, naming },
stages: { extraction, audit },
runs: capped,
};
}
-3
View File
@@ -27,7 +27,6 @@ import type {
SessionCheckpoint,
TurnUsage,
NotesSource,
NamingRunUsage,
} from "./contracts.ts";
import type { UserSettings } from "./settings.ts";
@@ -102,8 +101,6 @@ export interface GraphStore {
/** Append one cluster-naming run's usage (graph-global money channel) —
* idempotent per startedAt. */
appendNamingUsage(entry: NamingRunUsage): void;
readNamingRuns(): NamingRunUsage[];
/** Upsert one imported-notes source record (config.SOURCES_FILE — a
* JSON map, not a ledger; an upsert keeps the existing usage log). */
+5 -6
View File
@@ -42,14 +42,14 @@ export function graphCosts(): GraphCostSummary {
.readSources()
.filter((s) => s.extractions?.length)
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources, naming: stores.graphStore.readNamingRuns() });
return graphCostSummary({ chats, sources });
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
* across every graph data file — ledgers, positions, embeddings. Moves
* on ANY graph write (merge append, settle, embed, or a deletion's
* atomic rewrite), never moves on reads, and needs no bookkeeping of
* its own: the fs IS the counter. Consumers poll it (the activity feed
* across every graph data file — ledgers, positions. Moves on ANY
* graph write (merge append, settle, or a deletion's atomic rewrite),
* never moves on reads, and needs no bookkeeping of its own: the fs
* IS the counter. Consumers poll it (the activity feed
* carries it; the DELETE response returns it) to know clusters/layout
* must refetch — the "graph changed underneath me" signal. */
export function graphRevision(): number {
@@ -61,7 +61,6 @@ export function graphRevision(): number {
LEDGER_FILES.mappings,
LEDGER_FILES.sessions,
LEDGER_FILES.coverage,
LEDGER_FILES.namingRuns,
POSITIONS_FILE,
]) {
try {
-25
View File
@@ -42,7 +42,6 @@ import type {
ProvenancePoint,
CoverageEntry,
SessionCheckpoint,
NamingRunUsage,
} from "../core/contracts.ts";
import type { GraphStore } from "../core/ports.ts";
@@ -89,18 +88,6 @@ function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
isRecord(v) && isString(v.chatId) && isString(v.messageId);
const isNamingStats = (v: unknown): v is NamingRunUsage["stats"] =>
isRecord(v) &&
["detected", "claimed", "named", "appended"].every((k) => typeof v[k] === "number");
const isNamingRun = (v: unknown): v is NamingRunUsage =>
isRecord(v) &&
typeof v.startedAt === "number" &&
isString(v.at) &&
typeof v.durationMs === "number" &&
isString(v.model) &&
Array.isArray(v.steps) &&
isNamingStats(v.stats);
const isGraphNode = (v: unknown): v is GraphNode =>
isRecord(v) &&
@@ -190,18 +177,6 @@ export const graphStore: GraphStore = {
return readValid(LEDGER_FILES.coverage, isCoverageEntry);
},
// ── cluster-naming run usage (config.LEDGER_FILES.namingRuns) ──
/** Idempotent per startedAt — the same discipline as every usage
* channel (chats.appendExtractionUsage, appendSourceUsage). */
appendNamingUsage(entry: NamingRunUsage): void {
if (this.readNamingRuns().some((r) => r.startedAt === entry.startedAt)) return;
appendLine(LEDGER_FILES.namingRuns, entry);
},
readNamingRuns(): NamingRunUsage[] {
return readValid(LEDGER_FILES.namingRuns, isNamingRun);
},
// ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ──
/** Upsert: keeps the existing record's usage log when re-importing. */
+10 -24
View File
@@ -12,7 +12,7 @@ import {
type CostSummaryInput,
type GraphCostSummary,
} from "../src/core/cost-summary.ts";
import type { CallUsage, ExtractionRunUsage, NamingRunUsage } from "../src/core/contracts.ts";
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
import { PRICES, callCost } from "../src/core/prices.ts";
/** A model with full published pricing (from the generated registry). */
@@ -43,40 +43,30 @@ const extractionRun = (
...(stats ? { stats } : {}),
});
const namingRun = (startedAt: number, model: string, appended: number): NamingRunUsage => ({
startedAt,
at: new Date(startedAt).toISOString(),
durationMs: 4_000,
model,
steps: [step(500, 50)],
stats: { detected: 8, claimed: 6, named: 2, appended },
});
describe("graphCostSummary (the cost pill's fold)", () => {
test("folds all three channels: stage totals, tokens, newest-first runs", () => {
test("folds both channels: stage totals, tokens, newest-first runs", () => {
// (The cluster-naming channel died with the extraction-UX close-out,
// 2026-09-24 — two channels: chat + notes front doors.)
const input: CostSummaryInput = {
chats: [{ title: "Testing session", extractions: [extractionRun(100, PRICED, PRICED)] }],
sources: [{ filePath: "/vault/modal.md", extractions: [extractionRun(200, PRICED, PRICED)] }],
naming: [namingRun(300, PRICED, 2)],
};
const s = graphCostSummary(input);
const extStep = callCost(step(1000, 200), PRICED)!;
const audStep = callCost(step(2000, 100), PRICED)!;
assert.equal(s.stages.extraction.dollars, extStep * 2);
assert.equal(s.stages.audit.dollars, audStep * 2);
assert.equal(s.stages.naming.dollars, callCost(step(500, 50), PRICED));
assert.equal(s.stages.extraction.runs, 2);
assert.equal(s.stages.naming.runs, 1);
assert.equal(s.stages.extraction.tokens.inputTokens, 2000);
assert.equal(s.stages.audit.tokens.outputTokens, 200);
assert.equal(s.totalDollars, extStep * 2 + audStep * 2 + callCost(step(500, 50), PRICED)!);
// All three runs, newest first, titles joined per front door.
assert.equal(s.totalDollars, extStep * 2 + audStep * 2);
// Both runs, newest first, titles joined per front door — every
// run is kind "extraction" now.
assert.deepEqual(
s.runs.map((r) => r.title),
["Cluster naming", "/vault/modal.md", "Testing session"],
["/vault/modal.md", "Testing session"],
);
assert.equal(s.runs[0]!.labels, 2);
assert.equal(s.runs[0]!.kind, "naming");
assert.deepEqual(s.runs.map((r) => r.kind), ["extraction", "extraction"]);
assert.equal(s.unpricedRuns, 0);
});
@@ -84,7 +74,6 @@ describe("graphCostSummary (the cost pill's fold)", () => {
const s: GraphCostSummary = graphCostSummary({
chats: [{ title: "partial", extractions: [extractionRun(100, PRICED, "unknown-model")] }],
sources: [{ filePath: "/vault/x.md", extractions: [extractionRun(200, "unknown-model", "unknown-model")] }],
naming: [],
});
assert.equal(s.stages.audit.unpricedRuns, 2);
assert.equal(s.stages.audit.dollars, 0); // never a silent zero: unpricedRuns says it
@@ -101,7 +90,6 @@ describe("graphCostSummary (the cost pill's fold)", () => {
const s = graphCostSummary({
chats: [{ title: "chat", extractions }],
sources: [],
naming: [],
recentCap: 2,
});
assert.equal(s.runs.length, 2);
@@ -114,13 +102,11 @@ describe("graphCostSummary (the cost pill's fold)", () => {
const s = graphCostSummary({
chats: [{ title: "failed", extractions: [extractionRun(100, PRICED, PRICED)] }],
sources: [],
naming: [],
});
assert.equal(s.runs[0]!.merged, undefined);
const empty = graphCostSummary({ chats: [], sources: [], naming: [] });
const empty = graphCostSummary({ chats: [], sources: [] });
assert.equal(empty.totalDollars, 0);
assert.equal(empty.runs.length, 0);
assert.equal(empty.stages.naming.runs, 0);
assert.equal(empty.stages.extraction.tokens.inputTokens, undefined);
});
});
-8
View File
@@ -32,7 +32,6 @@ import type {
GraphEdge,
GraphNode,
LedgerEvent,
NamingRunUsage,
NodeMapping,
NotesSource,
SessionCheckpoint,
@@ -68,7 +67,6 @@ function memoryLedgers() {
const checkpoints: SessionCheckpoint[] = [];
const coverage: CoverageEntry[] = [];
const sources = new Map<string, NotesSource>();
const namingRuns: NamingRunUsage[] = [];
const positions = new Map<string, { x: number; y: number }>();
return {
appendNode: (n: GraphNode) => void nodes.push(n),
@@ -99,11 +97,6 @@ function memoryLedgers() {
}
},
// Cluster labelings — mirrors the real store: append dedupes by id.
// Cluster-naming run usage — mirrors the real store: dedupe per startedAt.
appendNamingUsage: (entry: NamingRunUsage) => {
if (!namingRuns.some((r) => r.startedAt === entry.startedAt)) namingRuns.push(entry);
},
readNamingRuns: () => namingRuns.map((r) => ({ ...r, steps: [...r.steps], stats: { ...r.stats } })),
// The layout ledger — settleGraphLayout writes it after each merge.
readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])),
writePositions: (next) => {
@@ -422,7 +415,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
// 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");
});
-1
View File
@@ -60,7 +60,6 @@ function fakeStores(
readClusterLabels: () => [],
appendClusterLabel: () => {},
appendNamingRun: () => {},
readNamingRuns: () => [],
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>
+1 -23
View File
@@ -18,7 +18,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts";
import type { AuditResult } from "../src/core/contracts.ts";
import type { QuestionAnswerData } from "../src/core/question-core.ts";
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
import { coverageSlots } from "../src/core/coverage.ts";
@@ -573,28 +573,6 @@ describe("graph ledger store", () => {
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
});
it("appendNamingUsage is idempotent per startedAt and strict-reads foreign shapes", () => {
const run: NamingRunUsage = {
startedAt: 1789800000000,
at: "2026-09-17T00:00:00.000Z",
durationMs: 1234,
model: "z-ai/glm-5.3-flash",
steps: [{ inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: undefined, reasoningTokens: 0 }],
stats: { detected: 4, claimed: 3, named: 1, appended: 1 },
};
graphStore.appendNamingUsage(run);
graphStore.appendNamingUsage(run); // re-append: same startedAt — no-op
// A torn append + a foreign shape (missing stats) — skipped by the guard.
fs.appendFileSync(
LEDGER_FILES.namingRuns,
`{not json}\n${JSON.stringify({ startedAt: 1789800000001, at: "x", durationMs: 1, model: "m", steps: [] })}\n`,
"utf-8",
);
const runs = graphStore.readNamingRuns().filter((r) => r.startedAt >= 1789800000000);
assert.equal(runs.length, 1, "one record — idempotent append, corrupt/foreign lines skipped");
assert.deepEqual(runs[0]!.stats, { detected: 4, claimed: 3, named: 1, appended: 1 });
assert.equal(runs[0]!.steps.length, 1);
});
it("returns RAW node lines — repeated ids (link-appends) are each their own line; getGraph merges", () => {
const line = (msgId: string) =>
-1
View File
@@ -54,7 +54,6 @@ function fakeStores(
(over.sources ?? []).map((s) => ({ ...s, extractions: [] }) as unknown as NotesSource),
readPositions: () => ({}),
writePositions: () => {},
readNamingRuns: () => [],
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>