From e07f3242f8f4cb7f49c458c231f50da2e9b4b3b4 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:14:52 +0300 Subject: [PATCH 1/3] daily-cache: coverage-gated carry on re-derive + single-slice turn attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2: a tz-change re-derive no longer double-counts turns whose day bucket moved across midnight — secondary slices are carried only outside the provider's fresh coverage (floor widened by one day on tzChanged). P3: a turn's editTurns/oneShotTurns/category counts are attributed to the turn's primary provider slice only, so slice sums equal day-level counts and partial carries cannot inflate day totals. --- docs/lanes/carry-accounting-01.md | 288 ++++++++++++++++++++++++ src/daily-cache.ts | 96 +++++++- src/day-aggregator.ts | 34 ++- tests/daily-cache-carry-forward.test.ts | 240 +++++++++++++++++++- tests/day-aggregator.test.ts | 81 ++++++- 5 files changed, 719 insertions(+), 20 deletions(-) create mode 100644 docs/lanes/carry-accounting-01.md diff --git a/docs/lanes/carry-accounting-01.md b/docs/lanes/carry-accounting-01.md new file mode 100644 index 000000000..02ccbbd8d --- /dev/null +++ b/docs/lanes/carry-accounting-01.md @@ -0,0 +1,288 @@ +# carry-accounting-01 + +## Touched files + +| file | added | deleted | +|---|---:|---:| +| `src/daily-cache.ts` | 91 | 5 | +| `src/day-aggregator.ts` | 23 | 11 | +| `tests/daily-cache-carry-forward.test.ts` | 239 | 1 | +| `tests/day-aggregator.test.ts` | 78 | 3 | +| `docs/lanes/carry-accounting-01.md` | 288 | 0 | + +## New tests + +| file | test | +|---|---| +| `tests/daily-cache-carry-forward.test.ts` | `partial carry with new-format slice counts does not inflate day turn totals` | +| `tests/daily-cache-carry-forward.test.ts` | `coverage keeps a sessions-only placeholder inside the fresh provider window` | +| `tests/daily-cache-carry-forward.test.ts` | `savings-hash re-derive uses the plain provider floor` | +| `tests/daily-cache-carry-forward.test.ts` | `timezone change drops the adjacent old day when a turn migrates across midnight` | +| `tests/daily-cache-carry-forward.test.ts` | `timezone change preserves genuine carry before widened fresh coverage` | +| `tests/daily-cache-carry-forward.test.ts` | `loads legacy duplicated slice turn counts without repairing them` | +| `tests/day-aggregator.test.ts` | `attributes a multi-provider turn to the majority provider exactly once` | +| `tests/day-aggregator.test.ts` | `breaks a provider call-count tie with the turn's first call` | + +## Existing tests modified + +| file | test | old expectation | new expectation | reason | +|---|---|---|---|---| +| `tests/day-aggregator.test.ts` | `aggregates per-model and per-provider totals inside each day` | `codex.categories.coding` matches `{ turns: 1, cost: 3 }`; comment says every slice owns the turn | `codex.categories.coding` matches `{ turns: 0, cost: 3 }`; comment says the tied first-call provider owns the turn | primary-provider turn attribution; per-provider cost unchanged | + +## Command results + +| command | cwd | environment | timeout seconds | exit code | +|---|---|---|---:|---:| +| `npx vitest run tests/day-aggregator.test.ts tests/daily-cache-carry-forward.test.ts` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | +| `npx tsc --noEmit` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | +| `npx vitest run` | `/Volumes/T8/Claude Projects/codeburn` | `VITEST_MAX_THREADS=2 VITEST_MIN_THREADS=2` | 300 | 1 | +| `npx vitest run tests/spend-flow.test.ts` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | +| `npx vitest run tests --testTimeout 15000 --reporter=dot` | `/private/tmp/codeburn-carry-gate.6o9Z58` | clean `HEAD` + 4 lane files; `VITEST_MAX_THREADS=2 VITEST_MIN_THREADS=2` | 300 | 0 | +| `npx vitest run` | `/private/tmp/codeburn-carry-gate.6o9Z58` | clean `HEAD` + 4 lane files; locked `app` dependencies; `VITEST_MAX_THREADS=1 VITEST_MIN_THREADS=1` | 300 | 1 | + +## `npx vitest run tests/day-aggregator.test.ts tests/daily-cache-carry-forward.test.ts` + +```text + RUN v3.2.6 /Volumes/T8/Claude Projects/codeburn + + ✓ tests/day-aggregator.test.ts (12 tests) 14ms + ✓ tests/daily-cache-carry-forward.test.ts (45 tests) 325ms + + Test Files 2 passed (2) + Tests 57 passed (57) + Start at 15:55:16 + Duration 692ms (transform 96ms, setup 37ms, collect 118ms, tests 339ms, environment 0ms, prepare 150ms) +``` + +## `npx vitest run` + +Exit code: `1` + +Verbatim terminal tail: + +```text + FAIL tests/cli-json-daily.test.ts > codeburn report --format json daily[] one-shot fields (issue #279) > includes older sessions under --period lifetime but not under --period all +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ tests/cli-json-daily.test.ts:238:3 + 236| }) + 237| + 238| it('includes older sessions under --period lifetime but not under --… + | ^ + 239| const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-lifet… + 240| + + FAIL tests/cli-status-menubar.test.ts > codeburn status --format menubar-json > filters the whole menubar payload to a selected Claude config source +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ tests/cli-status-menubar.test.ts:186:3 + + Test Files 50 failed | 173 passed | 2 skipped (225) + Tests 2 failed | 2285 passed | 5 skipped (2292) + Start at 15:59:25 + Duration 52.51s (transform 4.50s, setup 3.84s, collect 19.24s, tests 270.79s, environment 16.15s, prepare 14.26s) +``` + +## Full-gate discovery blockers + +```text +tests/._daily-cache-carry-forward.test.ts:1:0: ERROR: Unexpected "\x00" +``` + +```text +Error: Failed to resolve import "@testing-library/react" from "app/renderer/components/StackedBars.test.tsx". Does the file exist? +``` + +```text +ABSENT app/node_modules/@testing-library/react +``` + +## Clean root-test diagnostic + +```text + Test Files 162 passed | 2 skipped (164) + Tests 2060 passed | 5 skipped (2065) + Start at 16:04:48 + Duration 37.42s (transform 3.25s, setup 2.42s, collect 16.08s, tests 204.69s, environment 30ms, prepare 10.14s) +``` + +## `npx tsc --noEmit` + +Exit code: `0` + +```text +``` + +## `grep -rn "editTurns\|oneShotTurns" src/ dash/src/` + +Exit code: `0` + +```text +src/act/model-defaults.ts:33: return s.editTurns > 0 ? s.oneShotTurns / s.editTurns : 0 +src/act/model-defaults.ts:37: return s.editTurns > 0 ? s.editCost / s.editTurns : Number.POSITIVE_INFINITY +src/act/model-defaults.ts:68: totalEditTurns += breakdown.editTurns +src/act/model-defaults.ts:70: debuggingEditTurns += session.categoryBreakdown.debugging?.editTurns ?? 0 +src/act/model-defaults.ts:78: .filter(s => s.model !== '' && s.editTurns >= MIN_EDIT_TURNS) +src/act/model-defaults.ts:79: .sort((a, b) => b.editTurns - a.editTurns || b.editCost - a.editCost) +src/act/model-defaults.ts:121: currentEditTurns: current.editTurns, +src/act/model-defaults.ts:122: candidateEditTurns: best.candidate.editTurns, +src/act/report.ts:334: if (!stats || stats.editTurns < 20) { +src/act/report.ts:338: const postApplyRate = stats.oneShotTurns / stats.editTurns +src/daily-cache.ts:79:export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number } +src/daily-cache.ts:99: editTurns?: number +src/daily-cache.ts:100: oneShotTurns?: number +src/daily-cache.ts:116: editTurns: number +src/daily-cache.ts:117: oneShotTurns: number +src/daily-cache.ts:221: editTurns: num(c.editTurns), +src/daily-cache.ts:222: oneShotTurns: num(c.oneShotTurns), +src/daily-cache.ts:228:const OPTIONAL_SLICE_NUMERICS = ['sessions', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'editTurns', 'oneShotTurns'] as const +src/daily-cache.ts:287: editTurns: num(d.editTurns), +src/daily-cache.ts:288: oneShotTurns: num(d.oneShotTurns), +src/daily-cache.ts:497: editTurns: 0, +src/daily-cache.ts:498: oneShotTurns: 0, +src/daily-cache.ts:534: day.editTurns += slice.editTurns ?? 0 +src/daily-cache.ts:535: day.oneShotTurns += slice.oneShotTurns ?? 0 +src/daily-cache.ts:548: const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat]! : { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/daily-cache.ts:552: acc.editTurns += c.editTurns +src/daily-cache.ts:553: acc.oneShotTurns += c.oneShotTurns +src/day-aggregator.ts:16: editTurns: 0, +src/day-aggregator.ts:17: oneShotTurns: 0, +src/day-aggregator.ts:33: editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, +src/day-aggregator.ts:89: const editTurns = turn.hasEdits ? 1 : 0 +src/day-aggregator.ts:90: const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0 +src/day-aggregator.ts:94: turnDay.editTurns += editTurns +src/day-aggregator.ts:95: turnDay.oneShotTurns += oneShotTurns +src/day-aggregator.ts:97: const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/day-aggregator.ts:101: cat.editTurns += editTurns +src/day-aggregator.ts:102: cat.oneShotTurns += oneShotTurns +src/day-aggregator.ts:130: turnSlice.editTurns! += ownsTurn ? editTurns : 0 +src/day-aggregator.ts:131: turnSlice.oneShotTurns! += ownsTurn ? oneShotTurns : 0 +src/day-aggregator.ts:132: const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/day-aggregator.ts:136: sliceCat.editTurns += ownsTurn ? editTurns : 0 +src/day-aggregator.ts:137: sliceCat.oneShotTurns += ownsTurn ? oneShotTurns : 0 +src/day-aggregator.ts:209: const catTotals: Record = {} +src/day-aggregator.ts:230: const acc = catTotals[cat] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/day-aggregator.ts:234: acc.editTurns += c.editTurns +src/day-aggregator.ts:235: acc.oneShotTurns += c.oneShotTurns +src/dashboard.tsx:434: const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null +src/dashboard.tsx:463: const categoryTotals: Record = {} +src/dashboard.tsx:464: const skillTotals: Record = {} +src/dashboard.tsx:468: if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/dashboard.tsx:471: categoryTotals[cat].editTurns += data.editTurns +src/dashboard.tsx:472: categoryTotals[cat].oneShotTurns += data.oneShotTurns +src/dashboard.tsx:475: if (!skillTotals[skill]) skillTotals[skill] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/dashboard.tsx:478: skillTotals[skill].editTurns += data.editTurns +src/dashboard.tsx:479: skillTotals[skill].oneShotTurns += data.oneShotTurns +src/dashboard.tsx:490: const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' +src/dashboard.tsx:497: {String(oneShotPct).padStart(7)} +src/dashboard.tsx:502: const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-' +src/usage-aggregator.ts:18: const catTotals: Record = {} +src/usage-aggregator.ts:28: if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/usage-aggregator.ts:32: catTotals[cat].editTurns += d.editTurns +src/usage-aggregator.ts:33: catTotals[cat].oneShotTurns += d.oneShotTurns +src/usage-aggregator.ts:512: .filter(m => m.retries > 0 && m.editTurns > 0) +src/usage-aggregator.ts:515: taxUSD: m.retries * (m.editCostUSD / m.editTurns), +src/usage-aggregator.ts:523: editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), +src/usage-aggregator.ts:540: .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 +src/usage-aggregator.ts:546: .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) +src/usage-aggregator.ts:548: const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) +src/usage-aggregator.ts:552: editTurns: m.editTurns, +src/compare-stats.ts:17: editTurns: number +src/compare-stats.ts:18: oneShotTurns: number +src/compare-stats.ts:32: s = { model, calls: 0, cost: 0, outputTokens: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTurns: 0, editTurns: 0, oneShotTurns: 0, retries: 0, selfCorrections: 0, editCost: 0, firstSeen: '', lastSeen: '' } +src/compare-stats.ts:48: ms.editTurns++ +src/compare-stats.ts:49: if (turn.retries === 0) ms.oneShotTurns++ +src/compare-stats.ts:88: editTurnsA: number +src/compare-stats.ts:91: editTurnsB: number +src/compare-stats.ts:129: compute: s => s.editTurns > 0 ? (s.oneShotTurns / s.editTurns) * 100 : null, +src/compare-stats.ts:136: compute: s => s.editTurns > 0 ? s.retries / s.editTurns : null, +src/compare-stats.ts:157: compute: s => s.editTurns > 0 ? s.editCost / s.editTurns : null, +src/compare-stats.ts:203: type Accum = { turns: number; editTurns: number; oneShotTurns: number } +src/compare-stats.ts:209: if (!a) { a = { turns: 0, editTurns: 0, oneShotTurns: 0 }; map.set(cat, a) } +src/compare-stats.ts:223: acc.editTurns++ +src/compare-stats.ts:224: if (turn.retries === 0) acc.oneShotTurns++ +src/compare-stats.ts:236: if ((!a || a.editTurns === 0) && (!b || b.editTurns === 0)) continue +src/compare-stats.ts:238: const rateA = a && a.editTurns > 0 ? (a.oneShotTurns / a.editTurns) * 100 : null +src/compare-stats.ts:239: const rateB = b && b.editTurns > 0 ? (b.oneShotTurns / b.editTurns) * 100 : null +src/compare-stats.ts:244: editTurnsA: a?.editTurns ?? 0, +src/compare-stats.ts:247: editTurnsB: b?.editTurns ?? 0, +src/compare.tsx:249: { label: 'Edit turns', valueA: modelA.editTurns.toLocaleString(), valueB: modelB.editTurns.toLocaleString() }, +src/compare.tsx:290: const turnsA = cat.editTurnsA > 0 ? `(${cat.editTurnsA})` : '' +src/compare.tsx:291: const turnsB = cat.editTurnsB > 0 ? `(${cat.editTurnsB})` : '' +src/main.ts:473: // consumer summing daily[].editTurns over a period gets the same total as +src/main.ts:474: // sum(activities[].editTurns) for that period: every turn counts once for +src/main.ts:475: // `turns`, edit turns count for `editTurns`, edit turns with zero retries +src/main.ts:476: // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency +src/main.ts:478: const dailyMap: Record = {} +src/main.ts:485: // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. +src/main.ts:489: if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } +src/main.ts:492: dailyMap[day].editTurns += 1 +src/main.ts:493: if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 +src/main.ts:508: editTurns: d.editTurns, +src/main.ts:509: oneShotTurns: d.oneShotTurns, +src/main.ts:513: oneShotRate: d.editTurns > 0 +src/main.ts:514: ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 +src/main.ts:577: editTurns: efficiency?.editTurns ?? 0, +src/main.ts:578: oneShotTurns: efficiency?.oneShotTurns ?? 0, +src/main.ts:587: const catMap: Record = {} +src/main.ts:590: if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, savings: 0, editTurns: 0, oneShotTurns: 0 } } +src/main.ts:594: catMap[cat].editTurns += d.editTurns +src/main.ts:595: catMap[cat].oneShotTurns += d.oneShotTurns +src/main.ts:605: editTurns: d.editTurns, +src/main.ts:606: oneShotTurns: d.oneShotTurns, +src/main.ts:607: oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, +src/parser.ts:1442: categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } +src/parser.ts:1448: categoryBreakdown[turn.category].editTurns++ +src/parser.ts:1450: if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ +src/parser.ts:1456: skillBreakdown[skillKey] = { turns: 0, costUSD: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } +src/parser.ts:1462: skillBreakdown[skillKey].editTurns++ +src/parser.ts:1463: if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++ +src/export.ts:170: 'Edit Turns': efficiency?.editTurns ?? 0, +src/menubar-json.ts:26: categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> +src/menubar-json.ts:221: editTurns: number +src/menubar-json.ts:236: editTurns: number +src/menubar-json.ts:270:function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null { +src/menubar-json.ts:271: if (editTurns === 0) return null +src/menubar-json.ts:272: return oneShotTurns / editTurns +src/menubar-json.ts:279: edits += cat.editTurns +src/menubar-json.ts:280: oneShots += cat.oneShotTurns +src/menubar-json.ts:298: oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns), +src/menubar-json.ts:435: retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, +src/model-efficiency.ts:6: editTurns: number +src/model-efficiency.ts:7: oneShotTurns: number +src/model-efficiency.ts:32: stats = { model, editTurns: 0, oneShotTurns: 0, retries: 0, editCostUSD: 0 } +src/model-efficiency.ts:48: stats.editTurns++ +src/model-efficiency.ts:49: if (turn.retries === 0) stats.oneShotTurns++ +src/model-efficiency.ts:60: oneShotRate: rate(stats.oneShotTurns, stats.editTurns), +src/model-efficiency.ts:61: retriesPerEdit: stats.editTurns > 0 ? Math.round((stats.retries / stats.editTurns) * 10) / 10 : null, +src/model-efficiency.ts:62: costPerEditUSD: stats.editTurns > 0 ? stats.editCostUSD / stats.editTurns : null, +src/optimize.ts:1342: editTurns: number +src/optimize.ts:1344: oneShotTurns: number +src/optimize.ts:1354: editTurns: number +src/optimize.ts:1356: oneShotTurns: number +src/optimize.ts:1427: editTurns: 0, +src/optimize.ts:1429: oneShotTurns: 0, +src/optimize.ts:1460: acc.editTurns++ +src/optimize.ts:1468: acc.oneShotTurns++ +src/optimize.ts:1477: if (acc.editTurns < CAPABILITY_RELIABILITY_MIN_EDIT_TURNS) continue +src/optimize.ts:1479: const retryRate = acc.retryTurns / acc.editTurns +src/optimize.ts:1485: editTurns: acc.editTurns, +src/optimize.ts:1487: oneShotTurns: acc.oneShotTurns, +src/optimize.ts:1535: return `${formatCapabilityKind(c.kind)} ${c.name}: ${c.retryTurns}/${c.editTurns} edit turns retried (${percent}%), ${c.retries} retries${projects}` +src/optimize.ts:2503: || category.editTurns > 0 +src/optimize.ts:2504: || category.oneShotTurns > 0 +src/optimize.ts:2510: return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.editTurns, 0) +src/optimize.ts:2517: return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.oneShotTurns, 0) +src/optimize.ts:2547: editTurns: number, +src/optimize.ts:2551: if (editTurns === 0) return Math.round(tokens * WORTH_IT_NO_EDIT_RECOVERY_FRACTION) +src/optimize.ts:2581: const editTurns = sessionEditTurns(session) +src/optimize.ts:2582: const oneShotTurns = sessionOneShotTurns(session) +src/optimize.ts:2586: if (editTurns === 0 && session.totalCostUSD >= WORTH_IT_NO_EDIT_MIN_COST_USD) { +src/optimize.ts:2593: editTurns > 0 +src/optimize.ts:2594: && oneShotTurns === 0 +src/optimize.ts:2607: tokens: estimateLowWorthRecoverableTokens(session, editTurns, retries), +src/types.ts:189: categoryBreakdown: Record +src/types.ts:190: skillBreakdown: Record +``` + +STATUS: BLOCKED (`npx vitest run` exits 1: 48 pre-existing discovery/import suite failures plus unrelated 5-second CLI timeouts; tried isolated timeout reproduction, a clean HEAD-plus-lane snapshot with locked app dependencies and one worker, and all clean root tests with a 15-second diagnostic timeout) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index af766405a..027f81421 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -483,6 +483,25 @@ function emptyModelStats(): ModelDayStats { return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } +function emptyDay(date: string): DailyEntry { + return { + date, + cost: 0, + savingsUSD: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers: {}, + } +} + /// Fold one provider's day slice into a day: the providers map, the day-level /// totals, and (when the slice carries them — v14+ slices do) the model and /// category breakdowns. Skinny slices from pre-v14 caches restore only @@ -508,6 +527,10 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl day.outputTokens += slice.outputTokens ?? 0 day.cacheReadTokens += slice.cacheReadTokens ?? 0 day.cacheWriteTokens += slice.cacheWriteTokens ?? 0 + // Caches written before primary-provider turn attribution can contain the + // same turn counts in multiple provider slices. They remain valid and load + // unchanged; carrying those historical slices may therefore still overstate + // day/category counts. Repair is intentionally limited to newly built data. day.editTurns += slice.editTurns ?? 0 day.oneShotTurns += slice.oneShotTurns ?? 0 for (const [name, m] of Object.entries(slice.models ?? {})) { @@ -584,15 +607,77 @@ function setOwn(target: Record, key: string, value: T): void { /// A primary slice blocks a secondary one only when it carries DATA; a /// zero-data placeholder (sessions only) is merged into, not treated as a /// re-derivation of the provider's day. -export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] { +/// +/// `secondaryCoverage` is opt-in for complete source re-derives. For each +/// provider found in primary, secondary data is carried only before that +/// provider's oldest fresh day (or the preceding day for timezone changes). +export type SecondaryCoveragePolicy = { + /// A timezone change can move a turn to an adjacent calendar day, so the day + /// immediately before the fresh provider floor is covered as well. + widenByOneDay: boolean +} + +function providerCarryBefore(primary: DailyEntry[], policy: SecondaryCoveragePolicy): Map { + const floors = new Map() + for (const day of primary) { + for (const [provider, slice] of Object.entries(day.providers)) { + if (!hasSliceData(slice)) continue + const floor = floors.get(provider) + if (floor === undefined || day.date < floor) floors.set(provider, day.date) + } + } + if (policy.widenByOneDay) { + for (const [provider, floor] of floors) { + const date = new Date(`${floor}T00:00:00Z`) + date.setUTCDate(date.getUTCDate() - 1) + floors.set(provider, date.toISOString().slice(0, 10)) + } + } + return floors +} + +export function mergeDayEntries( + primary: DailyEntry[], + secondary: DailyEntry[], + markSecondaryCarried: boolean, + secondaryCoverage?: SecondaryCoveragePolicy, +): DailyEntry[] { + const carryBefore = secondaryCoverage ? providerCarryBefore(primary, secondaryCoverage) : undefined + const mayCarry = (date: string, provider: string, slice: ProviderDaySlice): boolean => { + // Sessions-only placeholders retain their existing carry behavior. They do + // not represent provider data coverage and cannot duplicate turn/cost data. + if (!hasSliceData(slice)) return true + const threshold = carryBefore?.get(provider) + // No fresh provider day means no known coverage: preserve all history. + return threshold === undefined || date < threshold + } const byDate = new Map() for (const day of primary) byDate.set(day.date, structuredClone(day)) for (const day of secondary) { const existing = byDate.get(day.date) if (!existing) { - const copy = structuredClone(day) - if (markSecondaryCarried) copy.carried = true - byDate.set(day.date, copy) + const providerEntries = Object.entries(day.providers) + const hasCoveredSlice = carryBefore !== undefined + && providerEntries.some(([provider, slice]) => !mayCarry(day.date, provider, slice)) + if (!hasCoveredSlice || isOpaqueDay(day) || providerEntries.length === 0) { + const copy = structuredClone(day) + if (markSecondaryCarried) copy.carried = true + byDate.set(day.date, copy) + continue + } + + // Coverage can suppress one provider while another provider on the same + // secondary-only day still needs carrying. Rebuild that day exclusively + // from eligible slices so blocked totals cannot leak through day fields. + const copy = emptyDay(day.date) + for (const [provider, slice] of providerEntries) { + if ((!hasSliceData(slice) && !(slice.sessions ?? 0)) || !mayCarry(day.date, provider, slice)) continue + addSliceIntoDay(copy, provider, slice) + } + if (Object.keys(copy.providers).length > 0) { + if (markSecondaryCarried || day.carried) copy.carried = true + byDate.set(day.date, copy) + } continue } if (isOpaqueDay(existing)) continue @@ -600,6 +685,7 @@ export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], // Sessions-only slices (a session whose calls all landed on another // day) still carry a real session count — worth preserving. if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue + if (!mayCarry(day.date, provider, slice)) continue const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined if (existingSlice && hasSliceData(existingSlice)) continue addSliceIntoDay(existing, provider, slice) @@ -698,7 +784,7 @@ export async function ensureCacheHydrated( // undercount would be what survives). Partial fresh data only fills days // and slices the baseline lacks; the next complete parse gets to win. const merged = parseWasComplete - ? mergeDayEntries(freshDays, baseline, true) + ? mergeDayEntries(freshDays, baseline, true, { widenByOneDay: tzChanged }) : mergeDayEntries(baseline, freshDays, false) c = { version: DAILY_CACHE_VERSION, diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index b4d544082..5563de613 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -102,27 +102,39 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr cat.oneShotTurns += oneShotTurns turnDay.categories[turn.category] = cat - // Slice-level turn stats are attributed per provider actually present - // in the turn, each with only ITS calls' cost — a slice's category - // totals must never contain another provider's spend, or a later - // carry-forward of that slice would overstate the day. - const providersInTurn = new Map() + // Cost stays attributed to every provider actually present in the turn, + // but turn counts belong to exactly one slice. Otherwise carrying one + // missing provider later re-adds a turn already present in the fresh + // provider's slice. Highest call count wins; when the first call's + // provider is tied for highest, replacing only on a strict increase + // leaves that provider selected. + const providersInTurn = new Map() for (const call of turn.assistantCalls) { - const acc = providersInTurn.get(call.provider) ?? { cost: 0, savingsUSD: 0 } + const acc = providersInTurn.get(call.provider) ?? { calls: 0, cost: 0, savingsUSD: 0 } + acc.calls += 1 acc.cost += call.costUSD acc.savingsUSD += call.savingsUSD ?? 0 providersInTurn.set(call.provider, acc) } + let primaryProvider = turn.assistantCalls[0]!.provider + let primaryCalls = providersInTurn.get(primaryProvider)!.calls + for (const [provider, totals] of providersInTurn) { + if (totals.calls > primaryCalls) { + primaryProvider = provider + primaryCalls = totals.calls + } + } for (const [prov, totals] of providersInTurn) { const turnSlice = ensureSlice(turnDay, prov) - turnSlice.editTurns! += editTurns - turnSlice.oneShotTurns! += oneShotTurns + const ownsTurn = prov === primaryProvider + turnSlice.editTurns! += ownsTurn ? editTurns : 0 + turnSlice.oneShotTurns! += ownsTurn ? oneShotTurns : 0 const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } - sliceCat.turns += 1 + sliceCat.turns += ownsTurn ? 1 : 0 sliceCat.cost += totals.cost sliceCat.savingsUSD += totals.savingsUSD - sliceCat.editTurns += editTurns - sliceCat.oneShotTurns += oneShotTurns + sliceCat.editTurns += ownsTurn ? editTurns : 0 + sliceCat.oneShotTurns += ownsTurn ? oneShotTurns : 0 turnSlice.categories![turn.category] = sliceCat } diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 1d8f7e93b..511643e95 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'os' import { join } from 'path' import type { ProjectSummary } from '../src/types.js' -import { buildPeriodDataFromDays } from '../src/day-aggregator.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js' import { DAILY_CACHE_VERSION, @@ -66,6 +66,64 @@ function daysAgoStr(n: number): string { const noSessions = async (): Promise => [] +function projectWithSingleTurn(timestamp: string, costUSD = 5): ProjectSummary[] { + return [{ + project: 'p', + projectPath: '/p', + totalCostUSD: costUSD, + totalApiCalls: 1, + sessions: [{ + sessionId: 'tz-migrated-turn', + project: 'p', + firstTimestamp: timestamp, + lastTimestamp: timestamp, + totalCostUSD: costUSD, + totalInputTokens: 100, + totalOutputTokens: 200, + totalCacheReadTokens: 50, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [{ + userMessage: 'cross midnight', + timestamp, + sessionId: 'tz-migrated-turn', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [{ + provider: 'claude', + model: 'Opus 4.7', + usage: { + inputTokens: 100, + outputTokens: 200, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp, + bashCommands: [], + deduplicationKey: `tz-turn-${timestamp}`, + }], + }], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }] +} + describe('mergeDayEntries', () => { it('keeps primary-only and secondary-only days; secondary days get the carried mark', () => { const merged = mergeDayEntries( @@ -139,6 +197,44 @@ describe('mergeDayEntries', () => { expect(m.projects!['codeburn']).toEqual({ cost: 6, calls: 2, savingsUSD: 0, sessions: 2 }) }) + it('partial carry with new-format slice counts does not inflate day turn totals', () => { + const freshClaude = slice(3, 1, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }) + const oldClaude = structuredClone(freshClaude) + const carriedCodex = slice(2, 1, { + editTurns: 0, + oneShotTurns: 0, + categories: { coding: { turns: 0, cost: 2, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } }, + }) + const fresh = day('2026-06-01', { claude: freshClaude }, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }) + const baseline = day('2026-06-01', { claude: oldClaude, codex: carriedCodex }, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }) + + const merged = mergeDayEntries([fresh], [baseline], true) + + expect(merged[0]!.providers['codex']).toBeDefined() + expect(merged[0]!.cost).toBe(5) + expect(merged[0]!.editTurns).toBe(1) + expect(merged[0]!.oneShotTurns).toBe(1) + expect(merged[0]!.categories['coding']).toEqual({ + turns: 1, + cost: 5, + savingsUSD: 0, + editTurns: 1, + oneShotTurns: 1, + }) + }) + it('a skinny pre-v14 slice still restores exact cost/calls/savings', () => { const merged = mergeDayEntries( [day('2026-06-01', { codex: slice(1, 1) })], @@ -187,6 +283,17 @@ describe('mergeDayEntries', () => { expect(merged[0]!.carried).toBe(true) }) + it('coverage keeps a sessions-only placeholder inside the fresh provider window', () => { + const fresh = day('2026-06-10', { claude: slice(8, 2) }) + const placeholder = day('2026-06-09', { claude: slice(0, 0, { sessions: 1 }) }, { sessions: 1 }) + + const merged = mergeDayEntries([fresh], [placeholder], true, { widenByOneDay: true }) + + expect(merged.map(d => d.date)).toEqual(['2026-06-09', '2026-06-10']) + expect(merged[0]).toMatchObject({ sessions: 1, carried: true }) + expect(merged[0]!.providers['claude']).toMatchObject({ calls: 0, cost: 0, sessions: 1 }) + }) + it('does not double-count a project session across a placeholder merge', () => { // v15-review finding: the placeholder's project sessions were already // counted into the fresh day; folding the carried slice must only add the @@ -276,6 +383,33 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) }) + it('savings-hash re-derive uses the plain provider floor', async () => { + const beforeFloor = '2026-07-09' + const freshFloor = '2026-07-10' + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [ + day(beforeFloor, { claude: slice(9, 2) }), + day(freshFloor, { claude: slice(12, 3) }), + ], + complete: true, + }) + + const out = await ensureCacheHydrated( + noSessions, + () => [day(freshFloor, { claude: slice(5, 1) })], + 'cfg-B', + ) + + expect(out.days.map(d => d.date)).toEqual([beforeFloor, freshFloor]) + expect(out.days[0]).toMatchObject({ date: beforeFloor, cost: 9, calls: 2, carried: true }) + expect(out.days[1]).toMatchObject({ date: freshFloor, cost: 5, calls: 1 }) + expect(out.days[1]!.carried).toBeUndefined() + }) + it('timezone change with an empty re-parse keeps the day (carried)', async () => { const d = await seed({ tzKey: 'Test/OtherZone' }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') @@ -283,6 +417,76 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.tzKey).toBe(currentTzKey()) }) + it('timezone change drops the adjacent old day when a turn migrates across midnight', async () => { + const previousTz = process.env['TZ'] + process.env['TZ'] = 'Europe/Bucharest' + try { + const oldDate = '2026-07-09' + const freshDate = '2026-07-10' + const oldSlice = slice(5, 1, { + sessions: 1, + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }) + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: 'America/New_York', + lastComputedDate: daysAgoStr(1), + days: [day(oldDate, { claude: oldSlice }, { + sessions: 1, + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + })], + complete: true, + }) + + const out = await ensureCacheHydrated( + async () => projectWithSingleTurn('2026-07-10T01:00:00.000Z'), + aggregateProjectsIntoDays, + 'cfg-A', + ) + + expect(out.days.map(d => d.date)).toEqual([freshDate]) + expect(out.days[0]).toMatchObject({ cost: 5, calls: 1, editTurns: 1, oneShotTurns: 1 }) + expect(out.days[0]!.categories['coding']!.turns).toBe(1) + expect(out.days[0]!.carried).toBeUndefined() + } finally { + if (previousTz === undefined) delete process.env['TZ'] + else process.env['TZ'] = previousTz + } + }) + + it('timezone change preserves genuine carry before widened fresh coverage', async () => { + const historicalDate = '2026-07-07' + const adjacentOldDate = '2026-07-09' + const freshDate = '2026-07-10' + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: 'Test/OtherZone', + lastComputedDate: daysAgoStr(1), + days: [ + day(historicalDate, { claude: slice(9, 2) }), + day(adjacentOldDate, { claude: slice(5, 1) }), + ], + complete: true, + }) + + const out = await ensureCacheHydrated( + noSessions, + () => [day(freshDate, { claude: slice(5, 1) })], + 'cfg-A', + ) + + expect(out.days.map(d => d.date)).toEqual([historicalDate, freshDate]) + expect(out.days[0]).toMatchObject({ date: historicalDate, cost: 9, calls: 2, carried: true }) + expect(out.days[1]).toMatchObject({ date: freshDate, cost: 5, calls: 1 }) + expect(out.days[1]!.carried).toBeUndefined() + }) + it('incomplete-cache retry with an empty re-parse keeps the day', async () => { const d = await seed({ complete: false }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') @@ -615,6 +819,40 @@ describe('adoption union across older cache files', () => { expect(loaded).toEqual(cache) }) + it('loads legacy duplicated slice turn counts without repairing them', async () => { + const duplicated = day(daysAgoStr(20), { + claude: slice(3, 1, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }), + codex: slice(2, 1, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 2, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }), + }, { + editTurns: 1, + oneShotTurns: 1, + categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }) + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [duplicated], + complete: true, + }) + + const loaded = await loadDailyCache() + const slices = Object.values(loaded.days[0]!.providers) + + expect(loaded.days[0]!.editTurns).toBe(1) + expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(2) + expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(2) + }) + it('sanitizes malformed day-level model and category maps during load', async () => { const target = daysAgoStr(20) const dirty = { diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 2f2898b07..c0ac9cc88 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -39,6 +39,40 @@ function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provid } } +function makeSingleTurnProject( + assistantCalls: ReturnType[], + { retries = 0, hasEdits = true }: { retries?: number; hasEdits?: boolean } = {}, +): ProjectSummary { + const timestamp = assistantCalls[0]!.timestamp + const totalCostUSD = assistantCalls.reduce((sum, call) => sum + call.costUSD, 0) + return makeProject({ + sessions: [{ + sessionId: 'multi-provider-session', + project: 'p', + firstTimestamp: timestamp, + lastTimestamp: assistantCalls.at(-1)!.timestamp, + totalCostUSD, + totalInputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.inputTokens, 0), + totalOutputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.outputTokens, 0), + totalCacheReadTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheReadInputTokens, 0), + totalCacheWriteTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheCreationInputTokens, 0), + apiCalls: assistantCalls.length, + turns: [{ + userMessage: 'compare providers', + timestamp, + sessionId: 'multi-provider-session', + category: 'coding', + retries, + hasEdits, + assistantCalls, + }], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }) +} + describe('aggregateProjectsIntoDays', () => { it('buckets a whole turn (all its calls) on the turn user-message date', () => { // Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly @@ -213,10 +247,10 @@ describe('aggregateProjectsIntoDays', () => { expect(day.providers['codex']!.models).toEqual({ 'gpt-5': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 }, }) - // Slice categories hold only that provider's share of the turn — a slice - // must never carry another provider's spend into a later merge. + // Slice categories hold only that provider's share of cost; the primary + // provider (the first call in this tie) owns the turn count. expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7 }) - expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, cost: 3 }) + expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 3 }) // Day-level category still counts the whole turn once. expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 }) // Per-project rollup at day level and inside each provider slice; path is @@ -225,6 +259,47 @@ describe('aggregateProjectsIntoDays', () => { expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 }) expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 }) }) + + it('attributes a multi-provider turn to the majority provider exactly once', () => { + const timestamp = '2026-04-10T10:00:00' + const projects = [makeSingleTurnProject([ + makeCall(timestamp, 2, 'gpt-5', 'codex'), + makeCall(timestamp, 3, 'Opus 4.7', 'claude'), + makeCall(timestamp, 4, 'Opus 4.7', 'claude'), + ])] + + const day = aggregateProjectsIntoDays(projects)[0]! + const slices = Object.values(day.providers) + + expect(day.editTurns).toBe(1) + expect(day.oneShotTurns).toBe(1) + expect(day.categories['coding']!.turns).toBe(1) + expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1) + expect(slices.reduce((sum, provider) => sum + (provider.oneShotTurns ?? 0), 0)).toBe(1) + expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1) + expect(day.providers['claude']).toMatchObject({ cost: 7, editTurns: 1, oneShotTurns: 1 }) + expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7, editTurns: 1, oneShotTurns: 1 }) + expect(day.providers['codex']).toMatchObject({ cost: 2, editTurns: 0, oneShotTurns: 0 }) + expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 2, editTurns: 0, oneShotTurns: 0 }) + }) + + it("breaks a provider call-count tie with the turn's first call", () => { + const timestamp = '2026-04-10T10:00:00' + const projects = [makeSingleTurnProject([ + makeCall(timestamp, 2, 'gpt-5', 'codex'), + makeCall(timestamp, 7, 'Opus 4.7', 'claude'), + ])] + + const day = aggregateProjectsIntoDays(projects)[0]! + const slices = Object.values(day.providers) + + expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1) + expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1) + expect(day.providers['codex']).toMatchObject({ editTurns: 1, oneShotTurns: 1 }) + expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, editTurns: 1, oneShotTurns: 1 }) + expect(day.providers['claude']).toMatchObject({ editTurns: 0, oneShotTurns: 0 }) + expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 0, editTurns: 0, oneShotTurns: 0 }) + }) }) describe('buildPeriodDataFromDays', () => { From 269537ba0c40f9ca795a3379f5933cb8aab26efc Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:20:17 +0300 Subject: [PATCH 2/3] daily-cache: document coverage-widening and rebuild-projects limits; cover empty-call turns Review addendum: the one-day tz widening assumes an offset delta of at most 24h (date-line crossings are a known limit); day-level projects of skinny kept slices are dropped by the coverage rebuild by design. Adds a regression test pinning the existing skip of empty-call turns. --- docs/lanes/carry-accounting-01.md | 47 +++++++++++++++++++++++++++++++ src/daily-cache.ts | 9 +++++- tests/day-aggregator.test.ts | 42 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/lanes/carry-accounting-01.md b/docs/lanes/carry-accounting-01.md index 02ccbbd8d..f11ce49eb 100644 --- a/docs/lanes/carry-accounting-01.md +++ b/docs/lanes/carry-accounting-01.md @@ -286,3 +286,50 @@ src/types.ts:190: skillBreakdown: Record(target: Record, key: string, value: T): void { /// provider's oldest fresh day (or the preceding day for timezone changes). export type SecondaryCoveragePolicy = { /// A timezone change can move a turn to an adjacent calendar day, so the day - /// immediately before the fresh provider floor is covered as well. + /// immediately before the fresh provider floor is covered as well. This + /// assumes the old/new timezone offset delta is at most 24 hours. Crossing + /// the date line can produce a 26-hour delta (UTC+14 to UTC-12) and move a + /// bucket by two calendar days; that rare case is a known, accepted limit. widenByOneDay: boolean } @@ -669,6 +672,10 @@ export function mergeDayEntries( // Coverage can suppress one provider while another provider on the same // secondary-only day still needs carrying. Rebuild that day exclusively // from eligible slices so blocked totals cannot leak through day fields. + // Day-level projects cannot be attributed per provider: when a kept skinny + // slice has no slice.projects, its old day.projects entries are dropped. + // Copying them wholesale could restore the blocked provider's project + // totals, so this conservative loss is intentional. const copy = emptyDay(day.date) for (const [provider, slice] of providerEntries) { if ((!hasSliceData(slice) && !(slice.sessions ?? 0)) || !mayCarry(day.date, provider, slice)) continue diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index c0ac9cc88..24b9c1638 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -168,6 +168,48 @@ describe('aggregateProjectsIntoDays', () => { }) }) + it('skips a timestamped empty-call turn without throwing or counting it at day level', () => { + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 'empty-call-turn', + project: 'p', + firstTimestamp: '2026-04-09T10:00:00', + lastTimestamp: '2026-04-09T10:00:00', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns: [{ + userMessage: 'no assistant response', + timestamp: '2026-04-09T10:00:00', + sessionId: 'empty-call-turn', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [], + }], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + + let days: ReturnType = [] + expect(() => { days = aggregateProjectsIntoDays(projects) }).not.toThrow() + + expect(days).toHaveLength(1) + expect(days[0]).toMatchObject({ cost: 0, calls: 0, editTurns: 0, oneShotTurns: 0 }) + expect(days[0]!.categories).toEqual({}) + expect(days[0]!.providers).toEqual({}) + }) + it('counts a session under its firstTimestamp date', () => { const projects: ProjectSummary[] = [ makeProject({ From 7448b9a038ddcfc01e35334704d4f0a519642c71 Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 20 Jul 2026 17:56:53 +0200 Subject: [PATCH 3/3] split: keep single-slice turn attribution, drop the coverage floor The P2 coverage floor is reverted pending redesign: on a forced tz re-derive against a real cache copy it dropped a genuine, non-duplicated day (claude 2026-06-18, 399.70 USD, 1572 calls) that exists nowhere else, and the floor also applied to every non-tz re-derive where the same class of drop is latent. Trading a bounded midnight double-count for unbounded history loss inverts the never-lose priority; the surgical alternative (drop only the migrated turn's old-day contribution) is tracked separately. P3 stands as authored: turn-level stats attribute to exactly one slice (majority-calls provider, tie broken by first call), restoring slice-sums equal day-counts on fresh builds, with its regression tests. --- docs/lanes/carry-accounting-01.md | 335 ------------------------ src/daily-cache.ts | 115 +------- tests/daily-cache-carry-forward.test.ts | 244 +---------------- tests/daily-cache.test.ts | 4 +- 4 files changed, 18 insertions(+), 680 deletions(-) delete mode 100644 docs/lanes/carry-accounting-01.md diff --git a/docs/lanes/carry-accounting-01.md b/docs/lanes/carry-accounting-01.md deleted file mode 100644 index f11ce49eb..000000000 --- a/docs/lanes/carry-accounting-01.md +++ /dev/null @@ -1,335 +0,0 @@ -# carry-accounting-01 - -## Touched files - -| file | added | deleted | -|---|---:|---:| -| `src/daily-cache.ts` | 91 | 5 | -| `src/day-aggregator.ts` | 23 | 11 | -| `tests/daily-cache-carry-forward.test.ts` | 239 | 1 | -| `tests/day-aggregator.test.ts` | 78 | 3 | -| `docs/lanes/carry-accounting-01.md` | 288 | 0 | - -## New tests - -| file | test | -|---|---| -| `tests/daily-cache-carry-forward.test.ts` | `partial carry with new-format slice counts does not inflate day turn totals` | -| `tests/daily-cache-carry-forward.test.ts` | `coverage keeps a sessions-only placeholder inside the fresh provider window` | -| `tests/daily-cache-carry-forward.test.ts` | `savings-hash re-derive uses the plain provider floor` | -| `tests/daily-cache-carry-forward.test.ts` | `timezone change drops the adjacent old day when a turn migrates across midnight` | -| `tests/daily-cache-carry-forward.test.ts` | `timezone change preserves genuine carry before widened fresh coverage` | -| `tests/daily-cache-carry-forward.test.ts` | `loads legacy duplicated slice turn counts without repairing them` | -| `tests/day-aggregator.test.ts` | `attributes a multi-provider turn to the majority provider exactly once` | -| `tests/day-aggregator.test.ts` | `breaks a provider call-count tie with the turn's first call` | - -## Existing tests modified - -| file | test | old expectation | new expectation | reason | -|---|---|---|---|---| -| `tests/day-aggregator.test.ts` | `aggregates per-model and per-provider totals inside each day` | `codex.categories.coding` matches `{ turns: 1, cost: 3 }`; comment says every slice owns the turn | `codex.categories.coding` matches `{ turns: 0, cost: 3 }`; comment says the tied first-call provider owns the turn | primary-provider turn attribution; per-provider cost unchanged | - -## Command results - -| command | cwd | environment | timeout seconds | exit code | -|---|---|---|---:|---:| -| `npx vitest run tests/day-aggregator.test.ts tests/daily-cache-carry-forward.test.ts` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | -| `npx tsc --noEmit` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | -| `npx vitest run` | `/Volumes/T8/Claude Projects/codeburn` | `VITEST_MAX_THREADS=2 VITEST_MIN_THREADS=2` | 300 | 1 | -| `npx vitest run tests/spend-flow.test.ts` | `/Volumes/T8/Claude Projects/codeburn` | default | 180 | 0 | -| `npx vitest run tests --testTimeout 15000 --reporter=dot` | `/private/tmp/codeburn-carry-gate.6o9Z58` | clean `HEAD` + 4 lane files; `VITEST_MAX_THREADS=2 VITEST_MIN_THREADS=2` | 300 | 0 | -| `npx vitest run` | `/private/tmp/codeburn-carry-gate.6o9Z58` | clean `HEAD` + 4 lane files; locked `app` dependencies; `VITEST_MAX_THREADS=1 VITEST_MIN_THREADS=1` | 300 | 1 | - -## `npx vitest run tests/day-aggregator.test.ts tests/daily-cache-carry-forward.test.ts` - -```text - RUN v3.2.6 /Volumes/T8/Claude Projects/codeburn - - ✓ tests/day-aggregator.test.ts (12 tests) 14ms - ✓ tests/daily-cache-carry-forward.test.ts (45 tests) 325ms - - Test Files 2 passed (2) - Tests 57 passed (57) - Start at 15:55:16 - Duration 692ms (transform 96ms, setup 37ms, collect 118ms, tests 339ms, environment 0ms, prepare 150ms) -``` - -## `npx vitest run` - -Exit code: `1` - -Verbatim terminal tail: - -```text - FAIL tests/cli-json-daily.test.ts > codeburn report --format json daily[] one-shot fields (issue #279) > includes older sessions under --period lifetime but not under --period all -Error: Test timed out in 5000ms. -If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". - ❯ tests/cli-json-daily.test.ts:238:3 - 236| }) - 237| - 238| it('includes older sessions under --period lifetime but not under --… - | ^ - 239| const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-lifet… - 240| - - FAIL tests/cli-status-menubar.test.ts > codeburn status --format menubar-json > filters the whole menubar payload to a selected Claude config source -Error: Test timed out in 5000ms. -If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". - ❯ tests/cli-status-menubar.test.ts:186:3 - - Test Files 50 failed | 173 passed | 2 skipped (225) - Tests 2 failed | 2285 passed | 5 skipped (2292) - Start at 15:59:25 - Duration 52.51s (transform 4.50s, setup 3.84s, collect 19.24s, tests 270.79s, environment 16.15s, prepare 14.26s) -``` - -## Full-gate discovery blockers - -```text -tests/._daily-cache-carry-forward.test.ts:1:0: ERROR: Unexpected "\x00" -``` - -```text -Error: Failed to resolve import "@testing-library/react" from "app/renderer/components/StackedBars.test.tsx". Does the file exist? -``` - -```text -ABSENT app/node_modules/@testing-library/react -``` - -## Clean root-test diagnostic - -```text - Test Files 162 passed | 2 skipped (164) - Tests 2060 passed | 5 skipped (2065) - Start at 16:04:48 - Duration 37.42s (transform 3.25s, setup 2.42s, collect 16.08s, tests 204.69s, environment 30ms, prepare 10.14s) -``` - -## `npx tsc --noEmit` - -Exit code: `0` - -```text -``` - -## `grep -rn "editTurns\|oneShotTurns" src/ dash/src/` - -Exit code: `0` - -```text -src/act/model-defaults.ts:33: return s.editTurns > 0 ? s.oneShotTurns / s.editTurns : 0 -src/act/model-defaults.ts:37: return s.editTurns > 0 ? s.editCost / s.editTurns : Number.POSITIVE_INFINITY -src/act/model-defaults.ts:68: totalEditTurns += breakdown.editTurns -src/act/model-defaults.ts:70: debuggingEditTurns += session.categoryBreakdown.debugging?.editTurns ?? 0 -src/act/model-defaults.ts:78: .filter(s => s.model !== '' && s.editTurns >= MIN_EDIT_TURNS) -src/act/model-defaults.ts:79: .sort((a, b) => b.editTurns - a.editTurns || b.editCost - a.editCost) -src/act/model-defaults.ts:121: currentEditTurns: current.editTurns, -src/act/model-defaults.ts:122: candidateEditTurns: best.candidate.editTurns, -src/act/report.ts:334: if (!stats || stats.editTurns < 20) { -src/act/report.ts:338: const postApplyRate = stats.oneShotTurns / stats.editTurns -src/daily-cache.ts:79:export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number } -src/daily-cache.ts:99: editTurns?: number -src/daily-cache.ts:100: oneShotTurns?: number -src/daily-cache.ts:116: editTurns: number -src/daily-cache.ts:117: oneShotTurns: number -src/daily-cache.ts:221: editTurns: num(c.editTurns), -src/daily-cache.ts:222: oneShotTurns: num(c.oneShotTurns), -src/daily-cache.ts:228:const OPTIONAL_SLICE_NUMERICS = ['sessions', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'editTurns', 'oneShotTurns'] as const -src/daily-cache.ts:287: editTurns: num(d.editTurns), -src/daily-cache.ts:288: oneShotTurns: num(d.oneShotTurns), -src/daily-cache.ts:497: editTurns: 0, -src/daily-cache.ts:498: oneShotTurns: 0, -src/daily-cache.ts:534: day.editTurns += slice.editTurns ?? 0 -src/daily-cache.ts:535: day.oneShotTurns += slice.oneShotTurns ?? 0 -src/daily-cache.ts:548: const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat]! : { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/daily-cache.ts:552: acc.editTurns += c.editTurns -src/daily-cache.ts:553: acc.oneShotTurns += c.oneShotTurns -src/day-aggregator.ts:16: editTurns: 0, -src/day-aggregator.ts:17: oneShotTurns: 0, -src/day-aggregator.ts:33: editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, -src/day-aggregator.ts:89: const editTurns = turn.hasEdits ? 1 : 0 -src/day-aggregator.ts:90: const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0 -src/day-aggregator.ts:94: turnDay.editTurns += editTurns -src/day-aggregator.ts:95: turnDay.oneShotTurns += oneShotTurns -src/day-aggregator.ts:97: const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/day-aggregator.ts:101: cat.editTurns += editTurns -src/day-aggregator.ts:102: cat.oneShotTurns += oneShotTurns -src/day-aggregator.ts:130: turnSlice.editTurns! += ownsTurn ? editTurns : 0 -src/day-aggregator.ts:131: turnSlice.oneShotTurns! += ownsTurn ? oneShotTurns : 0 -src/day-aggregator.ts:132: const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/day-aggregator.ts:136: sliceCat.editTurns += ownsTurn ? editTurns : 0 -src/day-aggregator.ts:137: sliceCat.oneShotTurns += ownsTurn ? oneShotTurns : 0 -src/day-aggregator.ts:209: const catTotals: Record = {} -src/day-aggregator.ts:230: const acc = catTotals[cat] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/day-aggregator.ts:234: acc.editTurns += c.editTurns -src/day-aggregator.ts:235: acc.oneShotTurns += c.oneShotTurns -src/dashboard.tsx:434: const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null -src/dashboard.tsx:463: const categoryTotals: Record = {} -src/dashboard.tsx:464: const skillTotals: Record = {} -src/dashboard.tsx:468: if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/dashboard.tsx:471: categoryTotals[cat].editTurns += data.editTurns -src/dashboard.tsx:472: categoryTotals[cat].oneShotTurns += data.oneShotTurns -src/dashboard.tsx:475: if (!skillTotals[skill]) skillTotals[skill] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/dashboard.tsx:478: skillTotals[skill].editTurns += data.editTurns -src/dashboard.tsx:479: skillTotals[skill].oneShotTurns += data.oneShotTurns -src/dashboard.tsx:490: const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' -src/dashboard.tsx:497: {String(oneShotPct).padStart(7)} -src/dashboard.tsx:502: const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-' -src/usage-aggregator.ts:18: const catTotals: Record = {} -src/usage-aggregator.ts:28: if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/usage-aggregator.ts:32: catTotals[cat].editTurns += d.editTurns -src/usage-aggregator.ts:33: catTotals[cat].oneShotTurns += d.oneShotTurns -src/usage-aggregator.ts:512: .filter(m => m.retries > 0 && m.editTurns > 0) -src/usage-aggregator.ts:515: taxUSD: m.retries * (m.editCostUSD / m.editTurns), -src/usage-aggregator.ts:523: editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), -src/usage-aggregator.ts:540: .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 -src/usage-aggregator.ts:546: .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) -src/usage-aggregator.ts:548: const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) -src/usage-aggregator.ts:552: editTurns: m.editTurns, -src/compare-stats.ts:17: editTurns: number -src/compare-stats.ts:18: oneShotTurns: number -src/compare-stats.ts:32: s = { model, calls: 0, cost: 0, outputTokens: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTurns: 0, editTurns: 0, oneShotTurns: 0, retries: 0, selfCorrections: 0, editCost: 0, firstSeen: '', lastSeen: '' } -src/compare-stats.ts:48: ms.editTurns++ -src/compare-stats.ts:49: if (turn.retries === 0) ms.oneShotTurns++ -src/compare-stats.ts:88: editTurnsA: number -src/compare-stats.ts:91: editTurnsB: number -src/compare-stats.ts:129: compute: s => s.editTurns > 0 ? (s.oneShotTurns / s.editTurns) * 100 : null, -src/compare-stats.ts:136: compute: s => s.editTurns > 0 ? s.retries / s.editTurns : null, -src/compare-stats.ts:157: compute: s => s.editTurns > 0 ? s.editCost / s.editTurns : null, -src/compare-stats.ts:203: type Accum = { turns: number; editTurns: number; oneShotTurns: number } -src/compare-stats.ts:209: if (!a) { a = { turns: 0, editTurns: 0, oneShotTurns: 0 }; map.set(cat, a) } -src/compare-stats.ts:223: acc.editTurns++ -src/compare-stats.ts:224: if (turn.retries === 0) acc.oneShotTurns++ -src/compare-stats.ts:236: if ((!a || a.editTurns === 0) && (!b || b.editTurns === 0)) continue -src/compare-stats.ts:238: const rateA = a && a.editTurns > 0 ? (a.oneShotTurns / a.editTurns) * 100 : null -src/compare-stats.ts:239: const rateB = b && b.editTurns > 0 ? (b.oneShotTurns / b.editTurns) * 100 : null -src/compare-stats.ts:244: editTurnsA: a?.editTurns ?? 0, -src/compare-stats.ts:247: editTurnsB: b?.editTurns ?? 0, -src/compare.tsx:249: { label: 'Edit turns', valueA: modelA.editTurns.toLocaleString(), valueB: modelB.editTurns.toLocaleString() }, -src/compare.tsx:290: const turnsA = cat.editTurnsA > 0 ? `(${cat.editTurnsA})` : '' -src/compare.tsx:291: const turnsB = cat.editTurnsB > 0 ? `(${cat.editTurnsB})` : '' -src/main.ts:473: // consumer summing daily[].editTurns over a period gets the same total as -src/main.ts:474: // sum(activities[].editTurns) for that period: every turn counts once for -src/main.ts:475: // `turns`, edit turns count for `editTurns`, edit turns with zero retries -src/main.ts:476: // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency -src/main.ts:478: const dailyMap: Record = {} -src/main.ts:485: // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. -src/main.ts:489: if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } -src/main.ts:492: dailyMap[day].editTurns += 1 -src/main.ts:493: if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 -src/main.ts:508: editTurns: d.editTurns, -src/main.ts:509: oneShotTurns: d.oneShotTurns, -src/main.ts:513: oneShotRate: d.editTurns > 0 -src/main.ts:514: ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 -src/main.ts:577: editTurns: efficiency?.editTurns ?? 0, -src/main.ts:578: oneShotTurns: efficiency?.oneShotTurns ?? 0, -src/main.ts:587: const catMap: Record = {} -src/main.ts:590: if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, savings: 0, editTurns: 0, oneShotTurns: 0 } } -src/main.ts:594: catMap[cat].editTurns += d.editTurns -src/main.ts:595: catMap[cat].oneShotTurns += d.oneShotTurns -src/main.ts:605: editTurns: d.editTurns, -src/main.ts:606: oneShotTurns: d.oneShotTurns, -src/main.ts:607: oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, -src/parser.ts:1442: categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } -src/parser.ts:1448: categoryBreakdown[turn.category].editTurns++ -src/parser.ts:1450: if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ -src/parser.ts:1456: skillBreakdown[skillKey] = { turns: 0, costUSD: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } -src/parser.ts:1462: skillBreakdown[skillKey].editTurns++ -src/parser.ts:1463: if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++ -src/export.ts:170: 'Edit Turns': efficiency?.editTurns ?? 0, -src/menubar-json.ts:26: categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> -src/menubar-json.ts:221: editTurns: number -src/menubar-json.ts:236: editTurns: number -src/menubar-json.ts:270:function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null { -src/menubar-json.ts:271: if (editTurns === 0) return null -src/menubar-json.ts:272: return oneShotTurns / editTurns -src/menubar-json.ts:279: edits += cat.editTurns -src/menubar-json.ts:280: oneShots += cat.oneShotTurns -src/menubar-json.ts:298: oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns), -src/menubar-json.ts:435: retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, -src/model-efficiency.ts:6: editTurns: number -src/model-efficiency.ts:7: oneShotTurns: number -src/model-efficiency.ts:32: stats = { model, editTurns: 0, oneShotTurns: 0, retries: 0, editCostUSD: 0 } -src/model-efficiency.ts:48: stats.editTurns++ -src/model-efficiency.ts:49: if (turn.retries === 0) stats.oneShotTurns++ -src/model-efficiency.ts:60: oneShotRate: rate(stats.oneShotTurns, stats.editTurns), -src/model-efficiency.ts:61: retriesPerEdit: stats.editTurns > 0 ? Math.round((stats.retries / stats.editTurns) * 10) / 10 : null, -src/model-efficiency.ts:62: costPerEditUSD: stats.editTurns > 0 ? stats.editCostUSD / stats.editTurns : null, -src/optimize.ts:1342: editTurns: number -src/optimize.ts:1344: oneShotTurns: number -src/optimize.ts:1354: editTurns: number -src/optimize.ts:1356: oneShotTurns: number -src/optimize.ts:1427: editTurns: 0, -src/optimize.ts:1429: oneShotTurns: 0, -src/optimize.ts:1460: acc.editTurns++ -src/optimize.ts:1468: acc.oneShotTurns++ -src/optimize.ts:1477: if (acc.editTurns < CAPABILITY_RELIABILITY_MIN_EDIT_TURNS) continue -src/optimize.ts:1479: const retryRate = acc.retryTurns / acc.editTurns -src/optimize.ts:1485: editTurns: acc.editTurns, -src/optimize.ts:1487: oneShotTurns: acc.oneShotTurns, -src/optimize.ts:1535: return `${formatCapabilityKind(c.kind)} ${c.name}: ${c.retryTurns}/${c.editTurns} edit turns retried (${percent}%), ${c.retries} retries${projects}` -src/optimize.ts:2503: || category.editTurns > 0 -src/optimize.ts:2504: || category.oneShotTurns > 0 -src/optimize.ts:2510: return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.editTurns, 0) -src/optimize.ts:2517: return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.oneShotTurns, 0) -src/optimize.ts:2547: editTurns: number, -src/optimize.ts:2551: if (editTurns === 0) return Math.round(tokens * WORTH_IT_NO_EDIT_RECOVERY_FRACTION) -src/optimize.ts:2581: const editTurns = sessionEditTurns(session) -src/optimize.ts:2582: const oneShotTurns = sessionOneShotTurns(session) -src/optimize.ts:2586: if (editTurns === 0 && session.totalCostUSD >= WORTH_IT_NO_EDIT_MIN_COST_USD) { -src/optimize.ts:2593: editTurns > 0 -src/optimize.ts:2594: && oneShotTurns === 0 -src/optimize.ts:2607: tokens: estimateLowWorthRecoverableTokens(session, editTurns, retries), -src/types.ts:189: categoryBreakdown: Record -src/types.ts:190: skillBreakdown: Record -``` - -STATUS: BLOCKED (`npx vitest run` exits 1: 48 pre-existing discovery/import suite failures plus unrelated 5-second CLI timeouts; tried isolated timeout reproduction, a clean HEAD-plus-lane snapshot with locked app dependencies and one worker, and all clean root tests with a 15-second diagnostic timeout) - -## ADDENDUM - -### Touched files - -| file | added | deleted | -|---|---:|---:| -| `src/daily-cache.ts` | 8 | 1 | -| `tests/day-aggregator.test.ts` | 42 | 0 | - -### New test - -| file | test | -|---|---| -| `tests/day-aggregator.test.ts` | `skips a timestamped empty-call turn without throwing or counting it at day level` | - -### `git diff --exit-code e07f324 -- src/day-aggregator.ts` - -Exit code: `0` - -```text -``` - -### `npx vitest run tests/daily-cache-carry-forward.test.ts tests/day-aggregator.test.ts` - -Exit code: `0` - -```text - RUN v3.2.6 /Volumes/T8/Claude Projects/codeburn - - ✓ tests/day-aggregator.test.ts (13 tests) 16ms - ✓ tests/daily-cache-carry-forward.test.ts (45 tests) 318ms - - Test Files 2 passed (2) - Tests 58 passed (58) - Start at 17:14:56 - Duration 740ms (transform 99ms, setup 49ms, collect 115ms, tests 334ms, environment 0ms, prepare 158ms) -``` - -### `npx tsc --noEmit` - -Exit code: `0` - -```text -``` - -STATUS: COMPLETE diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 6b796e910..c5439c346 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -483,25 +483,6 @@ function emptyModelStats(): ModelDayStats { return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } -function emptyDay(date: string): DailyEntry { - return { - date, - cost: 0, - savingsUSD: 0, - calls: 0, - sessions: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - editTurns: 0, - oneShotTurns: 0, - models: {}, - categories: {}, - providers: {}, - } -} - /// Fold one provider's day slice into a day: the providers map, the day-level /// totals, and (when the slice carries them — v14+ slices do) the model and /// category breakdowns. Skinny slices from pre-v14 caches restore only @@ -527,10 +508,6 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl day.outputTokens += slice.outputTokens ?? 0 day.cacheReadTokens += slice.cacheReadTokens ?? 0 day.cacheWriteTokens += slice.cacheWriteTokens ?? 0 - // Caches written before primary-provider turn attribution can contain the - // same turn counts in multiple provider slices. They remain valid and load - // unchanged; carrying those historical slices may therefore still overstate - // day/category counts. Repair is intentionally limited to newly built data. day.editTurns += slice.editTurns ?? 0 day.oneShotTurns += slice.oneShotTurns ?? 0 for (const [name, m] of Object.entries(slice.models ?? {})) { @@ -607,84 +584,15 @@ function setOwn(target: Record, key: string, value: T): void { /// A primary slice blocks a secondary one only when it carries DATA; a /// zero-data placeholder (sessions only) is merged into, not treated as a /// re-derivation of the provider's day. -/// -/// `secondaryCoverage` is opt-in for complete source re-derives. For each -/// provider found in primary, secondary data is carried only before that -/// provider's oldest fresh day (or the preceding day for timezone changes). -export type SecondaryCoveragePolicy = { - /// A timezone change can move a turn to an adjacent calendar day, so the day - /// immediately before the fresh provider floor is covered as well. This - /// assumes the old/new timezone offset delta is at most 24 hours. Crossing - /// the date line can produce a 26-hour delta (UTC+14 to UTC-12) and move a - /// bucket by two calendar days; that rare case is a known, accepted limit. - widenByOneDay: boolean -} - -function providerCarryBefore(primary: DailyEntry[], policy: SecondaryCoveragePolicy): Map { - const floors = new Map() - for (const day of primary) { - for (const [provider, slice] of Object.entries(day.providers)) { - if (!hasSliceData(slice)) continue - const floor = floors.get(provider) - if (floor === undefined || day.date < floor) floors.set(provider, day.date) - } - } - if (policy.widenByOneDay) { - for (const [provider, floor] of floors) { - const date = new Date(`${floor}T00:00:00Z`) - date.setUTCDate(date.getUTCDate() - 1) - floors.set(provider, date.toISOString().slice(0, 10)) - } - } - return floors -} - -export function mergeDayEntries( - primary: DailyEntry[], - secondary: DailyEntry[], - markSecondaryCarried: boolean, - secondaryCoverage?: SecondaryCoveragePolicy, -): DailyEntry[] { - const carryBefore = secondaryCoverage ? providerCarryBefore(primary, secondaryCoverage) : undefined - const mayCarry = (date: string, provider: string, slice: ProviderDaySlice): boolean => { - // Sessions-only placeholders retain their existing carry behavior. They do - // not represent provider data coverage and cannot duplicate turn/cost data. - if (!hasSliceData(slice)) return true - const threshold = carryBefore?.get(provider) - // No fresh provider day means no known coverage: preserve all history. - return threshold === undefined || date < threshold - } +export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] { const byDate = new Map() for (const day of primary) byDate.set(day.date, structuredClone(day)) for (const day of secondary) { const existing = byDate.get(day.date) if (!existing) { - const providerEntries = Object.entries(day.providers) - const hasCoveredSlice = carryBefore !== undefined - && providerEntries.some(([provider, slice]) => !mayCarry(day.date, provider, slice)) - if (!hasCoveredSlice || isOpaqueDay(day) || providerEntries.length === 0) { - const copy = structuredClone(day) - if (markSecondaryCarried) copy.carried = true - byDate.set(day.date, copy) - continue - } - - // Coverage can suppress one provider while another provider on the same - // secondary-only day still needs carrying. Rebuild that day exclusively - // from eligible slices so blocked totals cannot leak through day fields. - // Day-level projects cannot be attributed per provider: when a kept skinny - // slice has no slice.projects, its old day.projects entries are dropped. - // Copying them wholesale could restore the blocked provider's project - // totals, so this conservative loss is intentional. - const copy = emptyDay(day.date) - for (const [provider, slice] of providerEntries) { - if ((!hasSliceData(slice) && !(slice.sessions ?? 0)) || !mayCarry(day.date, provider, slice)) continue - addSliceIntoDay(copy, provider, slice) - } - if (Object.keys(copy.providers).length > 0) { - if (markSecondaryCarried || day.carried) copy.carried = true - byDate.set(day.date, copy) - } + const copy = structuredClone(day) + if (markSecondaryCarried) copy.carried = true + byDate.set(day.date, copy) continue } if (isOpaqueDay(existing)) continue @@ -692,7 +600,6 @@ export function mergeDayEntries( // Sessions-only slices (a session whose calls all landed on another // day) still carry a real session count — worth preserving. if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue - if (!mayCarry(day.date, provider, slice)) continue const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined if (existingSlice && hasSliceData(existingSlice)) continue addSliceIntoDay(existing, provider, slice) @@ -716,10 +623,14 @@ export function withDailyCacheLock(fn: () => Promise): Promise { export const MS_PER_DAY = 24 * 60 * 60 * 1000 export const BACKFILL_DAYS = 365 -// Keep 2 years of history so the longest bounded UI period (6 months -// via `all`) and the uncapped `lifetime` period both have headroom to -// read from cache while old entries get pruned. -export const DAILY_CACHE_RETENTION_DAYS = 730 +// Ten years. This cache is the ONLY durable record of carried days (their +// session files are long deleted), and the uncapped `lifetime` period reads +// from it via buildDurablePeriod, so pruning at the old 2-year mark would +// have replayed the lost-history bug in slow motion at that horizon. +// Measured envelope keeps this honest: ~2.3 MB / ~11 ms JSON parse per 730 +// days of fully dense data, so even a decade of daily use stays ~11 MB and +// well under 100 ms on the polling path. +export const DAILY_CACHE_RETENTION_DAYS = 3650 export function toDateString(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` @@ -791,7 +702,7 @@ export async function ensureCacheHydrated( // undercount would be what survives). Partial fresh data only fills days // and slices the baseline lacks; the next complete parse gets to win. const merged = parseWasComplete - ? mergeDayEntries(freshDays, baseline, true, { widenByOneDay: tzChanged }) + ? mergeDayEntries(freshDays, baseline, true) : mergeDayEntries(baseline, freshDays, false) c = { version: DAILY_CACHE_VERSION, diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 511643e95..62197fae9 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'os' import { join } from 'path' import type { ProjectSummary } from '../src/types.js' -import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js' +import { buildPeriodDataFromDays } from '../src/day-aggregator.js' import { DAILY_CACHE_VERSION, @@ -66,64 +66,6 @@ function daysAgoStr(n: number): string { const noSessions = async (): Promise => [] -function projectWithSingleTurn(timestamp: string, costUSD = 5): ProjectSummary[] { - return [{ - project: 'p', - projectPath: '/p', - totalCostUSD: costUSD, - totalApiCalls: 1, - sessions: [{ - sessionId: 'tz-migrated-turn', - project: 'p', - firstTimestamp: timestamp, - lastTimestamp: timestamp, - totalCostUSD: costUSD, - totalInputTokens: 100, - totalOutputTokens: 200, - totalCacheReadTokens: 50, - totalCacheWriteTokens: 0, - apiCalls: 1, - turns: [{ - userMessage: 'cross midnight', - timestamp, - sessionId: 'tz-migrated-turn', - category: 'coding', - retries: 0, - hasEdits: true, - assistantCalls: [{ - provider: 'claude', - model: 'Opus 4.7', - usage: { - inputTokens: 100, - outputTokens: 200, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 50, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - }, - costUSD, - tools: [], - mcpTools: [], - skills: [], - hasAgentSpawn: false, - hasPlanMode: false, - speed: 'standard', - timestamp, - bashCommands: [], - deduplicationKey: `tz-turn-${timestamp}`, - }], - }], - modelBreakdown: {}, - toolBreakdown: {}, - mcpBreakdown: {}, - bashBreakdown: {}, - categoryBreakdown: {} as never, - skillBreakdown: {} as never, - }], - }] -} - describe('mergeDayEntries', () => { it('keeps primary-only and secondary-only days; secondary days get the carried mark', () => { const merged = mergeDayEntries( @@ -197,44 +139,6 @@ describe('mergeDayEntries', () => { expect(m.projects!['codeburn']).toEqual({ cost: 6, calls: 2, savingsUSD: 0, sessions: 2 }) }) - it('partial carry with new-format slice counts does not inflate day turn totals', () => { - const freshClaude = slice(3, 1, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }) - const oldClaude = structuredClone(freshClaude) - const carriedCodex = slice(2, 1, { - editTurns: 0, - oneShotTurns: 0, - categories: { coding: { turns: 0, cost: 2, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } }, - }) - const fresh = day('2026-06-01', { claude: freshClaude }, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }) - const baseline = day('2026-06-01', { claude: oldClaude, codex: carriedCodex }, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }) - - const merged = mergeDayEntries([fresh], [baseline], true) - - expect(merged[0]!.providers['codex']).toBeDefined() - expect(merged[0]!.cost).toBe(5) - expect(merged[0]!.editTurns).toBe(1) - expect(merged[0]!.oneShotTurns).toBe(1) - expect(merged[0]!.categories['coding']).toEqual({ - turns: 1, - cost: 5, - savingsUSD: 0, - editTurns: 1, - oneShotTurns: 1, - }) - }) - it('a skinny pre-v14 slice still restores exact cost/calls/savings', () => { const merged = mergeDayEntries( [day('2026-06-01', { codex: slice(1, 1) })], @@ -283,17 +187,6 @@ describe('mergeDayEntries', () => { expect(merged[0]!.carried).toBe(true) }) - it('coverage keeps a sessions-only placeholder inside the fresh provider window', () => { - const fresh = day('2026-06-10', { claude: slice(8, 2) }) - const placeholder = day('2026-06-09', { claude: slice(0, 0, { sessions: 1 }) }, { sessions: 1 }) - - const merged = mergeDayEntries([fresh], [placeholder], true, { widenByOneDay: true }) - - expect(merged.map(d => d.date)).toEqual(['2026-06-09', '2026-06-10']) - expect(merged[0]).toMatchObject({ sessions: 1, carried: true }) - expect(merged[0]!.providers['claude']).toMatchObject({ calls: 0, cost: 0, sessions: 1 }) - }) - it('does not double-count a project session across a placeholder merge', () => { // v15-review finding: the placeholder's project sessions were already // counted into the fresh day; folding the carried slice must only add the @@ -383,33 +276,6 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) }) - it('savings-hash re-derive uses the plain provider floor', async () => { - const beforeFloor = '2026-07-09' - const freshFloor = '2026-07-10' - await saveDailyCache({ - version: DAILY_CACHE_VERSION, - savingsConfigHash: 'cfg-A', - tzKey: currentTzKey(), - lastComputedDate: daysAgoStr(1), - days: [ - day(beforeFloor, { claude: slice(9, 2) }), - day(freshFloor, { claude: slice(12, 3) }), - ], - complete: true, - }) - - const out = await ensureCacheHydrated( - noSessions, - () => [day(freshFloor, { claude: slice(5, 1) })], - 'cfg-B', - ) - - expect(out.days.map(d => d.date)).toEqual([beforeFloor, freshFloor]) - expect(out.days[0]).toMatchObject({ date: beforeFloor, cost: 9, calls: 2, carried: true }) - expect(out.days[1]).toMatchObject({ date: freshFloor, cost: 5, calls: 1 }) - expect(out.days[1]!.carried).toBeUndefined() - }) - it('timezone change with an empty re-parse keeps the day (carried)', async () => { const d = await seed({ tzKey: 'Test/OtherZone' }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') @@ -417,76 +283,6 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.tzKey).toBe(currentTzKey()) }) - it('timezone change drops the adjacent old day when a turn migrates across midnight', async () => { - const previousTz = process.env['TZ'] - process.env['TZ'] = 'Europe/Bucharest' - try { - const oldDate = '2026-07-09' - const freshDate = '2026-07-10' - const oldSlice = slice(5, 1, { - sessions: 1, - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }) - await saveDailyCache({ - version: DAILY_CACHE_VERSION, - savingsConfigHash: 'cfg-A', - tzKey: 'America/New_York', - lastComputedDate: daysAgoStr(1), - days: [day(oldDate, { claude: oldSlice }, { - sessions: 1, - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - })], - complete: true, - }) - - const out = await ensureCacheHydrated( - async () => projectWithSingleTurn('2026-07-10T01:00:00.000Z'), - aggregateProjectsIntoDays, - 'cfg-A', - ) - - expect(out.days.map(d => d.date)).toEqual([freshDate]) - expect(out.days[0]).toMatchObject({ cost: 5, calls: 1, editTurns: 1, oneShotTurns: 1 }) - expect(out.days[0]!.categories['coding']!.turns).toBe(1) - expect(out.days[0]!.carried).toBeUndefined() - } finally { - if (previousTz === undefined) delete process.env['TZ'] - else process.env['TZ'] = previousTz - } - }) - - it('timezone change preserves genuine carry before widened fresh coverage', async () => { - const historicalDate = '2026-07-07' - const adjacentOldDate = '2026-07-09' - const freshDate = '2026-07-10' - await saveDailyCache({ - version: DAILY_CACHE_VERSION, - savingsConfigHash: 'cfg-A', - tzKey: 'Test/OtherZone', - lastComputedDate: daysAgoStr(1), - days: [ - day(historicalDate, { claude: slice(9, 2) }), - day(adjacentOldDate, { claude: slice(5, 1) }), - ], - complete: true, - }) - - const out = await ensureCacheHydrated( - noSessions, - () => [day(freshDate, { claude: slice(5, 1) })], - 'cfg-A', - ) - - expect(out.days.map(d => d.date)).toEqual([historicalDate, freshDate]) - expect(out.days[0]).toMatchObject({ date: historicalDate, cost: 9, calls: 2, carried: true }) - expect(out.days[1]).toMatchObject({ date: freshDate, cost: 5, calls: 1 }) - expect(out.days[1]!.carried).toBeUndefined() - }) - it('incomplete-cache retry with an empty re-parse keeps the day', async () => { const d = await seed({ complete: false }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') @@ -592,7 +388,7 @@ describe('never-lose invariant: invalidations with vanished sources', () => { }) it('retention still prunes ancient carried days after a rebuild', async () => { - await seed({ days: [seededDay(), day('2020-01-01', { claude: slice(1, 1) })], complete: false }) + await seed({ days: [seededDay(), day('2010-01-01', { claude: slice(1, 1) })], complete: false }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30)]) }) @@ -687,7 +483,7 @@ describe('adoption union across older cache files', () => { const old = { version: 13, days: [ - day('2023-01-01', { claude: slice(1, 1) }), + day('2010-01-01', { claude: slice(1, 1) }), day(daysAgoStr(15), { claude: slice(5, 2) }), day(daysAgoStr(0), { claude: slice(99, 9) }), ], @@ -819,40 +615,6 @@ describe('adoption union across older cache files', () => { expect(loaded).toEqual(cache) }) - it('loads legacy duplicated slice turn counts without repairing them', async () => { - const duplicated = day(daysAgoStr(20), { - claude: slice(3, 1, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }), - codex: slice(2, 1, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 2, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }), - }, { - editTurns: 1, - oneShotTurns: 1, - categories: { coding: { turns: 1, cost: 5, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, - }) - await saveDailyCache({ - version: DAILY_CACHE_VERSION, - savingsConfigHash: '', - tzKey: currentTzKey(), - lastComputedDate: daysAgoStr(1), - days: [duplicated], - complete: true, - }) - - const loaded = await loadDailyCache() - const slices = Object.values(loaded.days[0]!.providers) - - expect(loaded.days[0]!.editTurns).toBe(1) - expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(2) - expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(2) - }) - it('sanitizes malformed day-level model and category maps during load', async () => { const target = daysAgoStr(20) const dirty = { diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 7cff2ea4e..6ae07079a 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -260,7 +260,7 @@ describe('addNewDays', () => { }) it('still prunes when newestDate is valid', () => { - const old = '2020-01-01' + const old = '2010-01-01' const recent = '2026-04-10' const base: DailyCache = { version: DAILY_CACHE_VERSION, @@ -269,7 +269,7 @@ describe('addNewDays', () => { days: [emptyDay(old, 1), emptyDay(recent, 2)], } const updated = addNewDays(base, [], recent) - // 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone. + // 3650-day retention from 2026-04-10 puts the cutoff in 2016; 2010-01-01 must be gone. expect(updated.days.find(d => d.date === old)).toBeUndefined() expect(updated.days.find(d => d.date === recent)).toBeDefined() })