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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user