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:
@@ -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>{" "}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ function fakeStores(
|
||||
readClusterLabels: () => [],
|
||||
appendClusterLabel: () => {},
|
||||
appendNamingRun: () => {},
|
||||
readNamingRuns: () => [],
|
||||
} as unknown as GraphStore;
|
||||
const chatStore: ChatStore = {
|
||||
listChats: () =>
|
||||
|
||||
+1
-23
@@ -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) =>
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user