From d5b22a3ac0407f09ca87b64687b29c2adc2ba0d0 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:40:56 -0700 Subject: [PATCH] fix(daily-cache): stop token-only slices inflating day totals on merge A slice with tokens but no calls, cost or savings (a Hermes or Copilot session total) read as a placeholder, so every adoption or re-derive merge replaced it while adding its tokens to the day and model rows again. Each version bump multiplied them by the number of older cache files holding the day. Count tokens as slice data, and cap day and model-row tokens at the slices' sum on load for days whose slices all carry token figures. A newer token-only slice now wins over an older one with calls, as the newer-wins rule intends, instead of the two being added. --- src/daily-cache.ts | 33 ++++++++++++++++++-- tests/daily-cache-carry-forward.test.ts | 41 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 3ded9cc7b..ea1609b99 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -673,6 +673,7 @@ const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/ export const CARRIED_MODEL_NAME = 'Unknown (carried)' const REMAINDER_KEYS = ['calls', 'cost', 'savingsUSD', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const +const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const /// Money is compared at the cent every surface reports it in. Summing a day's /// model rows in a different order than its total was accumulated leaves a @@ -704,6 +705,29 @@ function creditCarriedRemainder( setOwn(models, CARRIED_MODEL_NAME, acc) } +/// A token-only slice used to read as a placeholder, so every merge over it +/// added its tokens to the day again while the slice itself stayed whole. A day +/// whose slices all carry token figures never holds more than their sum; one +/// holding a pre-v14 slice without them keeps its own totals. +function healTokenTotals(day: DailyEntry): void { + const slices = Object.values(day.providers) + if (slices.length === 0 || slices.some(s => TOKEN_KEYS.every(k => s[k] === undefined))) return + const sums = TOKEN_KEYS.map(k => slices.reduce((sum, s) => sum + (s[k] ?? 0), 0)) + if (TOKEN_KEYS.every((k, i) => day[k] <= sums[i]!)) return + TOKEN_KEYS.forEach((k, i) => { day[k] = Math.min(day[k], sums[i]!) }) + const rows: Record = {} + for (const s of slices) { + for (const [name, m] of Object.entries(s.models ?? {})) { + const acc = Object.hasOwn(rows, name) ? rows[name]! : emptyModelStats() + for (const k of TOKEN_KEYS) acc[k] += m[k] + setOwn(rows, name, acc) + } + } + for (const [name, r] of Object.entries(rows)) { + if (Object.hasOwn(day.models, name)) for (const k of TOKEN_KEYS) day.models[name]![k] = Math.min(day.models[name]![k], r[k]) + } +} + function migrateDays(days: Record[]): DailyEntry[] { return days .filter(d => d && typeof d === 'object' && typeof d.date === 'string' && DATE_KEY_RE.test(d.date)) @@ -738,6 +762,7 @@ function migrateDays(days: Record[]): DailyEntry[] { // split while one of its slices was written before slices carried one (or // the reverse), and a provider-scoped view reads the slice's map alone. .map(day => { + healTokenTotals(day) for (const slice of Object.values(day.providers)) creditCarriedRemainder(slice, slice.models ??= {}) creditCarriedRemainder(day, day.models) return day @@ -983,8 +1008,11 @@ function applyRetention(days: DailyEntry[], newestDate: string): DailyEntry[] { return days.filter(d => d.date >= cutoff) } +/// Tokens count: a slice with usage but no call or cost (a Hermes session +/// total) is already in the day's totals, so merging over it as a placeholder +/// counts it twice. function hasSliceData(slice: ProviderDaySlice): boolean { - return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0 + return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0 || TOKEN_KEYS.some(k => (slice[k] ?? 0) > 0) } /// A day from a pre-v5-era cache: day-level totals exist but the providers map @@ -1180,7 +1208,8 @@ function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderD ...(categories ? { categories } : {}), ...(projects ? { projects } : {}), } - return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null + // A token-only remainder is drift between two scans of the same calls, not usage. + return calls > 0 || cost > 0 || savingsUSD > 0 || sessions > 0 ? out : null } /// Entries are subtracted key by key, so content the fresh parse files under a diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 5c36288e4..86972d589 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -761,6 +761,47 @@ describe('adoption union across older cache files', () => { }) }) +describe('token-only slices across migrations', () => { + const tokens = (inputTokens: number, outputTokens: number) => ({ inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 }) + const model = (calls: number, cost: number, inputTokens: number, outputTokens: number) => ({ calls, cost, savingsUSD: 0, ...tokens(inputTokens, outputTokens) }) + const tokenOnlyDay = (inputScale = 1) => day('2026-08-28', { + claude: slice(650, 3607, { sessions: 4, ...tokens(16569, 2000), models: { 'Opus 4.8': model(3607, 650, 16569, 2000) } }), + hermes: slice(0, 0, { sessions: 1, ...tokens(2898477, 16118), models: { 'minimax/MiniMax-M3': model(0, 0, 2898477, 16118) } }), + }, { + sessions: 5, + ...tokens(16569 + 2898477 * inputScale, 2000 + 16118 * inputScale), + models: { 'Opus 4.8': model(3607, 650, 16569, 2000), 'minimax/MiniMax-M3': model(0, 0, 2898477 * inputScale, 16118 * inputScale) }, + }) + const fileFor = (version: number, days: DailyEntry[]) => ({ version, savingsConfigHash: '', tzKey: currentTzKey(), lastComputedDate: '2026-08-28', complete: true, days }) + const totals = (d: DailyEntry) => ({ cost: d.cost, calls: d.calls, inputTokens: d.inputTokens, outputTokens: d.outputTokens, minimax: d.models['minimax/MiniMax-M3'], hermes: d.providers['hermes'] }) + + it('keeps a carried day with a 0-call model row identical over two version bumps', async () => { + for (const v of [DAILY_CACHE_VERSION - 3, DAILY_CACHE_VERSION - 2]) { + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${v}.json`), JSON.stringify(fileFor(v, [tokenOnlyDay()])), 'utf-8') + } + const expected = totals(tokenOnlyDay()) + + const first = await loadDailyCache() + expect(totals(first.days[0]!)).toEqual(expected) + + // The next bump: the file just written becomes an older generation too. + const written = JSON.parse(await readFile(dailyCachePath(), 'utf-8')) + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`), JSON.stringify({ ...written, version: DAILY_CACHE_VERSION - 1 }), 'utf-8') + await rm(dailyCachePath()) + const second = await loadDailyCache() + expect(totals(second.days[0]!)).toEqual(expected) + }) + + it('heals a day already inflated by it and leaves a pre-v14 day alone', async () => { + const preV14 = day('2026-06-16', { claude: slice(1.34, 267) }, { ...tokens(53249, 74664), models: { 'claude-sonnet-4-6': model(252, 1.34, 53249, 74664) } }) + await writeFile(dailyCachePath(), JSON.stringify(fileFor(DAILY_CACHE_VERSION, [preV14, tokenOnlyDay(10)])), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days[0]!.inputTokens).toBe(53249) + expect(cache.days[0]!.models['claude-sonnet-4-6']!.inputTokens).toBe(53249) + expect(totals(cache.days[1]!)).toEqual(totals(tokenOnlyDay())) + }) +}) + // ═══════════════════════════════════════════════════════════════════════════ // #946 validation round 5, item 2: the migration carried the copilot slice // verbatim instead of re-deriving it.