Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel

- Cluster namer bills the money channel now: per-step usage + model
  captured via onStep, persisted as NamingRunUsage records to
  data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
  runs persist too — money spent either way). callUsage promoted to
  extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
  notes-source runs, and naming runs into stage totals (extraction /
  audit / naming) + newest-first recent runs. Dollars INFERRED read-time
  via prices.ts, never stored; unpriced models excluded-but-counted
  (render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
  expandable breakdown panel (stage rows with tokens secondary, recent
  runs with time/title/merged/cost); fetched on mount, refetched when a
  pipeline run finishes; merged into one bottom-left stack with the
  running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
  naming path), store idempotency + strict reads.
This commit is contained in:
amosblomqvist
2026-09-19 20:55:35 +03:00
parent c32915b232
commit 3e263a18cc
14 changed files with 666 additions and 37 deletions
+19
View File
@@ -33,6 +33,7 @@ import type {
GraphEdge,
GraphNode,
LedgerEvent,
NamingRunUsage,
NodeMapping,
NotesSource,
SessionCheckpoint,
@@ -70,6 +71,7 @@ function memoryLedgers() {
const coverage: CoverageEntry[] = [];
const sources = new Map<string, NotesSource>();
const clusterLabels: ClusterLabeling[] = [];
const namingRuns: NamingRunUsage[] = [];
const positions = new Map<string, { x: number; y: number }>();
const embeddings = {
model: "",
@@ -111,6 +113,11 @@ function memoryLedgers() {
if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l);
},
readClusterLabels: () => clusterLabels.map((l) => ({ ...l, core: [...l.core] })),
// 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) => {
@@ -799,6 +806,18 @@ describe("cluster naming (the post-extraction garnish)", () => {
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 () => {