From 43f807769ac032508e8c924c8c501107a228debe Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:40:41 +0300 Subject: [PATCH] fix(daily-cache): surgical tz-migration de-dup for carried days (#770) On a tz-change full re-derive, mergeDayEntries carried a baseline slice whenever the fresh day had no data slice for that (date, provider), so a turn that re-bucketed across local midnight left its old day sliceless, got carried there, AND counted again on its new day. This subtracts from each carried baseline slice exactly what the fresh parse still attributes to that (date, provider) under the OLD bucketing (dateKeyInTz): the re-bucketed turns, nothing else. A sources-gone slice has no such content and survives untouched; a fully-explained slice is dropped; residual slices ADD their sessions instead of max-dedup, since the subtraction already removed the placeholder's share. --- src/daily-cache.ts | 353 ++++++++++++++++++++- src/day-aggregator.ts | 25 +- src/usage-aggregator.ts | 6 +- tests/daily-cache-tz-dedup.test.ts | 488 +++++++++++++++++++++++++++++ 4 files changed, 858 insertions(+), 14 deletions(-) create mode 100644 tests/daily-cache-tz-dedup.test.ts diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 1c6bf57b..3bcbc3a6 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -520,19 +520,32 @@ function emptyModelStats(): ModelDayStats { /// day but whose turns all landed on another) only contributes its session /// count, deduplicated by max — the same real session may be counted on both /// sides. -function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice): void { +/// `residual` marks a slice that came out of the tz subtraction (issue #770): +/// the subtraction already removed the placeholder's sessions (the ones the +/// fresh parse explained), so the residual sessions are all distinct from the +/// placeholder's and must ADD to it, not max-dedup against it. Max would clamp +/// max(placeholder, residual) and permanently drop the source-gone sessions the +/// residual still carries. +function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice, residual = false): void { // Reads keyed by names from foreign caches use hasOwn throughout: a plain // lookup of "__proto__" returns the prototype object, and accumulating into // it pollutes every object in the process. const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined const placeholderSessions = placeholder?.sessions ?? 0 const merged = structuredClone(slice) - if (placeholderSessions > (merged.sessions ?? 0)) merged.sessions = placeholderSessions + if (residual) { + // The subtraction removed the placeholder's sessions from this residual, so + // every remaining session is distinct from the placeholder's - add, don't + // max (max would clamp 1 + 1 to 1 and lose the source-gone session). + merged.sessions = placeholderSessions + (merged.sessions ?? 0) + } else if (placeholderSessions > (merged.sessions ?? 0)) { + merged.sessions = placeholderSessions + } setOwn(day.providers, provider, merged) day.cost += slice.cost day.calls += slice.calls day.savingsUSD += slice.savingsUSD ?? 0 - day.sessions += Math.max(0, (slice.sessions ?? 0) - placeholderSessions) + day.sessions += residual ? (slice.sessions ?? 0) : Math.max(0, (slice.sessions ?? 0) - placeholderSessions) day.inputTokens += slice.inputTokens ?? 0 day.outputTokens += slice.outputTokens ?? 0 day.cacheReadTokens += slice.cacheReadTokens ?? 0 @@ -572,7 +585,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl // project sessions were already counted into the day when the fresh day // was built, so only the excess is added. const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0 - acc.sessions += Math.max(0, num(p.sessions) - placeholderProjectSessions) + acc.sessions += residual ? num(p.sessions) : Math.max(0, num(p.sessions) - placeholderProjectSessions) setOwn(dayProjects, name, acc) } // Placeholder-only projects (session counted fresh, calls landed elsewhere) @@ -582,7 +595,11 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl for (const [name, p] of Object.entries(placeholderProjects)) { if (!p || typeof p !== 'object') continue if (Object.hasOwn(mergedProjects, name)) { - if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions) + if (residual) { + mergedProjects[name]!.sessions = num(mergedProjects[name]!.sessions) + num(p.sessions) + } else if (num(p.sessions) > num(mergedProjects[name]!.sessions)) { + mergedProjects[name]!.sessions = num(p.sessions) + } } else { setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) }) } @@ -598,6 +615,246 @@ function setOwn(target: Record, key: string, value: T): void { Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) } +// --- tz-aware carry subtraction (issue #770) --------------------------------- +// +// After a timezone change the full re-derive re-aggregates the same session +// parse under the CURRENT tz and merges it over the cached (old-tz) days. +// mergeDayEntries carries a baseline slice only when the fresh day has no data +// slice for that (date, provider), so a turn that re-bucketed across local +// midnight leaves its old day sliceless, gets carried there, AND counts again on +// its new day. The fix subtracts from each carried baseline slice the content +// the fresh parse still attributes to that (date, provider) under the OLD +// bucketing (`freshUnderOldTz`): exactly the re-bucketed turns, nothing else. +// A sources-gone slice has no such content and survives untouched; a slice fully +// explained away is dropped. + +/// Reduce `base` by `sub` at the slice level, clamping every field at 0 and +/// dropping nested entries that reduce to nothing. Returns null when no positive +/// data remains; the merge then drops the slice instead of carrying an empty +/// one. `sub` is always a subset of `base` in practice (same parse, old bucketing +/// vs cached baseline), so the clamp only guards rounding and cache/baseline skew. +function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderDaySlice | null { + const calls = Math.max(0, base.calls - (sub.calls ?? 0)) + const cost = Math.max(0, base.cost - (sub.cost ?? 0)) + const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0)) + const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0)) + const inputTokens = Math.max(0, (base.inputTokens ?? 0) - (sub.inputTokens ?? 0)) + const outputTokens = Math.max(0, (base.outputTokens ?? 0) - (sub.outputTokens ?? 0)) + const cacheReadTokens = Math.max(0, (base.cacheReadTokens ?? 0) - (sub.cacheReadTokens ?? 0)) + const cacheWriteTokens = Math.max(0, (base.cacheWriteTokens ?? 0) - (sub.cacheWriteTokens ?? 0)) + const editTurns = Math.max(0, (base.editTurns ?? 0) - (sub.editTurns ?? 0)) + const oneShotTurns = Math.max(0, (base.oneShotTurns ?? 0) - (sub.oneShotTurns ?? 0)) + const models = subtractModels(base.models, sub.models) + const categories = subtractCategories(base.categories, sub.categories) + const projects = subtractProjects(base.projects, sub.projects) + const out: ProviderDaySlice = { + calls, cost, savingsUSD, + ...(sessions > 0 ? { sessions } : {}), + ...(inputTokens > 0 ? { inputTokens } : {}), + ...(outputTokens > 0 ? { outputTokens } : {}), + ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}), + ...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}), + ...(editTurns > 0 ? { editTurns } : {}), + ...(oneShotTurns > 0 ? { oneShotTurns } : {}), + ...(models ? { models } : {}), + ...(categories ? { categories } : {}), + ...(projects ? { projects } : {}), + } + return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null +} + +function subtractModelStats(base: ModelDayStats, sub: ModelDayStats): ModelDayStats | null { + const calls = Math.max(0, base.calls - (sub.calls ?? 0)) + const cost = Math.max(0, base.cost - (sub.cost ?? 0)) + const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0)) + const inputTokens = Math.max(0, base.inputTokens - (sub.inputTokens ?? 0)) + const outputTokens = Math.max(0, base.outputTokens - (sub.outputTokens ?? 0)) + const cacheReadTokens = Math.max(0, base.cacheReadTokens - (sub.cacheReadTokens ?? 0)) + const cacheWriteTokens = Math.max(0, base.cacheWriteTokens - (sub.cacheWriteTokens ?? 0)) + if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null + return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } +} + +function subtractModels(base: DailyEntry['models'] | undefined, sub: DailyEntry['models'] | undefined): DailyEntry['models'] | undefined { + if (!base) return undefined + const out: DailyEntry['models'] = {} + for (const [name, stats] of Object.entries(base)) { + const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined + const reduced = s ? subtractModelStats(stats, s) : stats + if (reduced) setOwn(out, name, reduced) + } + return Object.keys(out).length > 0 ? out : undefined +} + +function subtractCategoryStats(base: CategoryDayStats, sub: CategoryDayStats): CategoryDayStats | null { + const turns = Math.max(0, base.turns - (sub.turns ?? 0)) + const cost = Math.max(0, base.cost - (sub.cost ?? 0)) + const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0)) + const editTurns = Math.max(0, base.editTurns - (sub.editTurns ?? 0)) + const oneShotTurns = Math.max(0, base.oneShotTurns - (sub.oneShotTurns ?? 0)) + if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null + return { turns, cost, savingsUSD, editTurns, oneShotTurns } +} + +function subtractCategories(base: DailyEntry['categories'] | undefined, sub: DailyEntry['categories'] | undefined): DailyEntry['categories'] | undefined { + if (!base) return undefined + const out: DailyEntry['categories'] = {} + for (const [name, stats] of Object.entries(base)) { + const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined + const reduced = s ? subtractCategoryStats(stats, s) : stats + if (reduced) setOwn(out, name, reduced) + } + return Object.keys(out).length > 0 ? out : undefined +} + +function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): ProjectDayStats | null { + const cost = Math.max(0, base.cost - (sub.cost ?? 0)) + const calls = Math.max(0, base.calls - (sub.calls ?? 0)) + const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0)) + const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0)) + if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null + return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) } +} + +function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined { + if (!base) return undefined + const out: DailyEntry['projects'] = {} + for (const [name, stats] of Object.entries(base)) { + const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined + const reduced = s ? subtractProjectStats(stats, s) : stats + if (reduced) setOwn(out, name, reduced) + } + return Object.keys(out).length > 0 ? out : undefined +} + +/// How much a nested stat entry actually lost: `base` before minus `reduced` +/// after, or null when nothing was lost. The raw `sub` is only a lower bound - +/// with tz skew it can exceed the slice, and subtracting it would eat OTHER +/// providers' share of the day-level breakdown. +function modelStatsDelta(base: ModelDayStats, reduced: ModelDayStats): ModelDayStats | null { + const calls = base.calls - reduced.calls + const cost = base.cost - reduced.cost + const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0) + const inputTokens = base.inputTokens - reduced.inputTokens + const outputTokens = base.outputTokens - reduced.outputTokens + const cacheReadTokens = base.cacheReadTokens - reduced.cacheReadTokens + const cacheWriteTokens = base.cacheWriteTokens - reduced.cacheWriteTokens + if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null + return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } +} + +function categoryStatsDelta(base: CategoryDayStats, reduced: CategoryDayStats): CategoryDayStats | null { + const turns = base.turns - reduced.turns + const cost = base.cost - reduced.cost + const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0) + const editTurns = base.editTurns - reduced.editTurns + const oneShotTurns = base.oneShotTurns - reduced.oneShotTurns + if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null + return { turns, cost, savingsUSD, editTurns, oneShotTurns } +} + +function projectStatsDelta(base: ProjectDayStats, reduced: ProjectDayStats): ProjectDayStats | null { + const cost = base.cost - reduced.cost + const calls = base.calls - reduced.calls + const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0) + const sessions = (base.sessions ?? 0) - (reduced.sessions ?? 0) + if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null + return { cost, calls, savingsUSD, sessions } +} + +/// Remove `sub`'s contribution from a carried baseline day (the baseline-only +/// date branch of the merge, where the whole day clones over). Reduces the +/// provider's slice, the day-level totals, and the day-level models/categories/ +/// projects maps that `addSliceIntoDay` would have grown them by. +/// +/// Every day-level subtraction uses the EFFECTIVE removal - what the provider +/// slice actually lost (current before minus reduced after) - not the raw `sub`. +/// With tz skew (`freshUnderOldTz` content larger than the baseline slice), the +/// raw sub exceeds the slice and subtracting it would over-remove the day's +/// totals and its nested maps, eating unrelated providers' carried history and +/// breaking the invariant that a day's totals sum to its slices. A provider +/// slice that was absent has an effective removal of zero: nothing is subtracted +/// from the day. +function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDaySlice): void { + const current = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined + if (!current) return + const reduced = subtractSlice(current, sub) + if (reduced) setOwn(day.providers, provider, reduced) + else delete day.providers[provider] + + day.cost = Math.max(0, day.cost - (current.cost - (reduced?.cost ?? 0))) + day.calls = Math.max(0, day.calls - (current.calls - (reduced?.calls ?? 0))) + day.savingsUSD = Math.max(0, (day.savingsUSD ?? 0) - ((current.savingsUSD ?? 0) - (reduced?.savingsUSD ?? 0))) + day.sessions = Math.max(0, day.sessions - ((current.sessions ?? 0) - (reduced?.sessions ?? 0))) + day.inputTokens = Math.max(0, day.inputTokens - ((current.inputTokens ?? 0) - (reduced?.inputTokens ?? 0))) + day.outputTokens = Math.max(0, day.outputTokens - ((current.outputTokens ?? 0) - (reduced?.outputTokens ?? 0))) + day.cacheReadTokens = Math.max(0, day.cacheReadTokens - ((current.cacheReadTokens ?? 0) - (reduced?.cacheReadTokens ?? 0))) + day.cacheWriteTokens = Math.max(0, day.cacheWriteTokens - ((current.cacheWriteTokens ?? 0) - (reduced?.cacheWriteTokens ?? 0))) + day.editTurns = Math.max(0, day.editTurns - ((current.editTurns ?? 0) - (reduced?.editTurns ?? 0))) + day.oneShotTurns = Math.max(0, day.oneShotTurns - ((current.oneShotTurns ?? 0) - (reduced?.oneShotTurns ?? 0))) + + for (const [name, m] of Object.entries(current.models ?? {})) { + const rm = reduced?.models && Object.hasOwn(reduced.models, name) ? reduced.models[name] : undefined + const removed = rm ? modelStatsDelta(m, rm) : m + if (!removed) continue + const acc = Object.hasOwn(day.models, name) ? day.models[name] : undefined + if (!acc) continue + const reducedM = subtractModelStats(acc, removed) + if (reducedM) setOwn(day.models, name, reducedM) + else delete day.models[name] + } + for (const [cat, c] of Object.entries(current.categories ?? {})) { + const rc = reduced?.categories && Object.hasOwn(reduced.categories, cat) ? reduced.categories[cat] : undefined + const removed = rc ? categoryStatsDelta(c, rc) : c + if (!removed) continue + const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat] : undefined + if (!acc) continue + const reducedC = subtractCategoryStats(acc, removed) + if (reducedC) setOwn(day.categories, cat, reducedC) + else delete day.categories[cat] + } + if (!day.projects) return + for (const [name, p] of Object.entries(current.projects ?? {})) { + const rp = reduced?.projects && Object.hasOwn(reduced.projects, name) ? reduced.projects[name] : undefined + const removed = rp ? projectStatsDelta(p, rp) : p + if (!removed) continue + const acc = Object.hasOwn(day.projects, name) ? day.projects[name] : undefined + if (!acc) continue + const reducedP = subtractProjectStats(acc, removed) + if (reducedP) setOwn(day.projects, name, reducedP) + else delete day.projects[name] + } +} + +/// Did the tz subtraction leave any positive data on a carried baseline day? +/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the +/// day level, extended to the day's other scalar and nested content. +function hasPositiveDayContent(day: DailyEntry): boolean { + if (day.cost > 0 || day.calls > 0 || (day.savingsUSD ?? 0) > 0 || day.sessions > 0) return true + if (day.inputTokens > 0 || day.outputTokens > 0 || day.cacheReadTokens > 0 || day.cacheWriteTokens > 0) return true + if (day.editTurns > 0 || day.oneShotTurns > 0) return true + if (Object.keys(day.providers).length > 0) return true + if (Object.keys(day.models).length > 0 || Object.keys(day.categories).length > 0) return true + if (day.projects && Object.keys(day.projects).length > 0) return true + return false +} + +/// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD +/// tzKey) by date then provider, so the merge can subtract exactly what the +/// fresh parse still explains under the old bucketing. +function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap> { + const byDate = new Map>() + for (const day of days) { + if (Object.keys(day.providers).length === 0) continue + const byProvider = new Map() + for (const [provider, slice] of Object.entries(day.providers)) { + byProvider.set(provider, slice) + } + byDate.set(day.date, byProvider) + } + return byDate +} + /// Merge two day lists per (date, provider): `primary` wins wherever both have /// data; `secondary` only fills dates primary lacks entirely and provider /// slices primary lacks on shared dates. Nothing in secondary can overwrite or @@ -613,13 +870,36 @@ 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[] { +/// `subtract`, present ONLY on the tz-change re-derive, maps (date, provider) +/// to the content the fresh parse still attributes there under the OLD +/// bucketing. Every baseline slice the merge would otherwise carry has that +/// content subtracted first (clamped at 0, dropped when nothing positive +/// remains), so turns that re-bucketed across local midnight are not counted on +/// both their old and new days. Absent (undefined) on every other path, which +/// keeps those merges byte-identical to the pre-fix behavior. +export function mergeDayEntries( + primary: DailyEntry[], + secondary: DailyEntry[], + markSecondaryCarried: boolean, + subtract?: ReadonlyMap>, +): 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 copy = structuredClone(day) + if (subtract) { + const subForDate = subtract.get(day.date) + if (subForDate) { + for (const [provider, slice] of Object.entries(copy.providers)) { + const subSlice = subForDate.get(provider) + if (!subSlice) continue + subtractSliceFromDay(copy, provider, subSlice) + } + if (!hasPositiveDayContent(copy)) continue + } + } if (markSecondaryCarried) copy.carried = true byDate.set(day.date, copy) continue @@ -631,7 +911,22 @@ export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined if (existingSlice && hasSliceData(existingSlice)) continue - addSliceIntoDay(existing, provider, slice) + let toAdd = slice + let residual = false + if (subtract) { + const subSlice = subtract.get(day.date)?.get(provider) + if (subSlice) { + const reduced = subtractSlice(slice, subSlice) + if (!reduced) continue + toAdd = reduced + // The subtraction already removed the sessions the fresh parse + // explained, so the residual's sessions are distinct from the fresh + // placeholder's: merging over it must ADD, not max-dedup (fix round + // 1 - max would drop the source-gone sessions the residual carries). + residual = true + } + } + addSliceIntoDay(existing, provider, toAdd, residual) if (markSecondaryCarried) existing.carried = true } } @@ -679,6 +974,12 @@ export async function ensureCacheHydrated( /// So the backfill is only marked `complete` when this returns true. Defaults /// to a trusting `true` for callers that don't (or can't) supply it. sessionComplete: () => boolean = () => true, + /// Re-aggregate the SAME parsed projects under an explicit timezone instead of + /// the machine's local one. Used only on a tz-change re-derive: the result is + /// compared against the fresh local-tz days to subtract the turns that + /// re-bucketed across local midnight from the carried baseline (issue #770). + /// Absent, the tz-change path carries forward exactly as it did before. + aggregateDaysInTz?: (projects: ProjectSummary[], tz: string) => DailyEntry[], ): Promise { const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) @@ -744,16 +1045,50 @@ export async function ensureCacheHydrated( const priorWatermark = c.lastComputedDate const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) let freshDays: DailyEntry[] = [] + let projects: ProjectSummary[] = [] if (backfillStart.getTime() <= yesterdayEnd.getTime()) { - freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd })) + // Hoisted so a tz-change re-derive can aggregate the SAME parse twice + // (once under the current tz as freshDays, once under the cache's old + // tzKey as freshUnderOldTz) without a second session parse. + // + // The parse stops at yesterdayEnd. Keeping it a HISTORY parse is what + // makes the parser slice a midnight-straddling turn at the yesterday + // boundary: day-N's turn-level category/counts then carry only the + // pre-midnight half and today's live parse carries the rest, so the two + // sides reconcile (issue #852). Widening THIS parse through now would + // leave the full turn on day N while today's half was excluded from the + // cache, breaking that reconciliation - so the subtraction below gets + // its own through-now parse instead. + projects = await parseSessions({ start: backfillStart, end: yesterdayEnd }) + freshDays = aggregateDays(projects) } const parseWasComplete = sessionComplete() // A PARTIAL parse must not overwrite finalized baseline days with // undercounts (if their sources die before the next complete parse, the // undercount would be what survives). Partial fresh data only fills days // and slices the baseline lacks; the next complete parse gets to win. + // + // On a complete-parse TZ re-derive (savings config untouched), subtract + // from each carried baseline slice the content the fresh parse still + // attributes to that (date, provider) under the OLD bucketing: the turns + // that re-bucketed across local midnight. That is the issue #770 + // double-count; re-pricing drift (a savings-hash change) must never be + // subtracted, so a hash change in the same re-derive skips this entirely. + let tzSubtraction: ReadonlyMap> | undefined + if (parseWasComplete && tzChanged && c.savingsConfigHash === savingsConfigHash && aggregateDaysInTz && c.tzKey !== undefined) { + // The subtraction re-parses THROUGH NOW (fix round 1): a call bucketed + // to OLD-tz yesterday that re-buckets to NEW-tz TODAY sits past the + // history parse's yesterdayEnd, so `freshUnderOldTz` built from `projects` + // would never see it - the baseline slice would be carried un-subtracted + // while today's live parse counts it again. This second parse exists + // ONLY for the subtraction; it never feeds freshDays, so the merged + // days written to the cache stay exactly the history days and today is + // still owned by the caller's live parse. + const wideProjects = await parseSessions({ start: backfillStart, end: now }) + tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey)) + } const merged = parseWasComplete - ? mergeDayEntries(freshDays, baseline, true) + ? mergeDayEntries(freshDays, baseline, true, tzSubtraction) : mergeDayEntries(baseline, freshDays, false) c = { version: DAILY_CACHE_VERSION, diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index cdac6a20..03695832 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -26,6 +26,23 @@ export function dateKey(iso: string): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } +/// Bucket an ISO timestamp under an explicit IANA timezone instead of the +/// machine's local one. `en-CA` emits the ISO-ish YYYY-MM-DD layout directly, +/// so formatToParts under the given `timeZone` yields exactly that shape. Used +/// to re-aggregate the same parse under a cache's OLD tzKey when a timezone +/// change forces a full re-derive (issue #770): comparing that bucketing to the +/// fresh one shows exactly which turns re-bucketed across local midnight. +export function dateKeyInTz(iso: string, tz: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso)) + let year = '', month = '', day = '' + for (const p of parts) { + if (p.type === 'year') year = p.value + else if (p.type === 'month') month = p.value + else if (p.type === 'day') day = p.value + } + return `${year}-${month}-${day}` +} + function emptySlice(): ProviderDaySlice { return { calls: 0, cost: 0, savingsUSD: 0, @@ -34,7 +51,7 @@ function emptySlice(): ProviderDaySlice { } } -export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] { +export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn: (iso: string) => string = dateKey): DailyEntry[] { const byDate = new Map() const ensure = (date: string): DailyEntry => { let d = byDate.get(date) @@ -61,7 +78,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const project of projects) { for (const session of project.sessions) { - const sessionDate = dateKey(session.firstTimestamp) + const sessionDate = dateKeyFn(session.firstTimestamp) const sessionDay = ensure(sessionDate) sessionDay.sessions += 1 ensureProject(sessionDay, session.project, project.projectPath).sessions += 1 @@ -94,7 +111,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr // sliced per call, per-call bucketing here was what caused the // constant offset against the whole-turn headline; the slice is // what makes it exact now.) - const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp) + const turnDate = dateKeyFn(turn.timestamp || turn.assistantCalls[0]!.timestamp) const turnDay = ensure(turnDate) const editTurns = turn.hasEdits ? 1 : 0 @@ -154,7 +171,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr // Call-derived values bucket under the call's OWN day (see the // two-rule comment above). An unparseable call timestamp falls back // to the turn's anchor day rather than producing a garbage date key. - const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp) + const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKeyFn(call.timestamp) const callDay = ensure(callDate) callDay.cost += call.costUSD diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 17dd2afe..82aeb732 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -6,7 +6,7 @@ import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesCo import { getAllProviders, safeDiscoverSessions } from './providers/index.js' import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { stat } from 'node:fs/promises' -import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModels } from './models-report.js' import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js' @@ -95,6 +95,10 @@ async function hydrateCache(): Promise { // Never finalize the daily history off a partial (interrupted) session // hydration — that is what froze empty older days into the chart. isSessionHydrationComplete, + // On a tz-change re-derive the same parse is re-aggregated under the old + // tzKey so carried slices can be reduced by the turns that re-bucketed + // across local midnight (issue #770). + (projects, tz) => aggregateProjectsIntoDays(projects, (iso) => dateKeyInTz(iso, tz)), ) } catch (err) { // Previously swallowed silently, which turned any backfill failure into an diff --git a/tests/daily-cache-tz-dedup.test.ts b/tests/daily-cache-tz-dedup.test.ts new file mode 100644 index 00000000..4c6a9430 --- /dev/null +++ b/tests/daily-cache-tz-dedup.test.ts @@ -0,0 +1,488 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { rm } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import type { DateRange, ProjectSummary } from '../src/types.js' +import { aggregateProjectsIntoDays, dateKey, dateKeyInTz } from '../src/day-aggregator.js' + +import { + DAILY_CACHE_VERSION, + type DailyCache, + type DailyEntry, + type ProviderDaySlice, + currentTzKey, + ensureCacheHydrated, + mergeDayEntries, + saveDailyCache, + toDateString, +} from '../src/daily-cache.js' + +const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-tz-dedup-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(() => { + process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-15T12:00:00.000Z')) +}) + +afterEach(async () => { + vi.useRealTimers() + if (existsSync(TMP_CACHE_ROOT)) { + await rm(TMP_CACHE_ROOT, { recursive: true, force: true }) + } +}) + +function slice(cost: number, calls: number, extra: Partial = {}): ProviderDaySlice { + return { cost, calls, savingsUSD: 0, ...extra } +} + +function day(date: string, providers: Record, overrides: Partial = {}): DailyEntry { + const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0) + const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0) + return { + date, + cost, + savingsUSD: 0, + calls, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers, + ...overrides, + } +} + +function makeCall(timestamp: string, costUSD: number, provider = 'codex') { + return { + provider, + model: 'codex-1', + 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' as const, + timestamp, + bashCommands: [], + deduplicationKey: `dk-${timestamp}-${costUSD}`, + } +} + +function makeProject(calls: ReturnType[]): ProjectSummary { + const timestamp = calls[0]!.timestamp + const totalCostUSD = calls.reduce((s, c) => s + c.costUSD, 0) + return { + project: 'p', + projectPath: '/p', + totalCostUSD, + totalApiCalls: calls.length, + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: timestamp, + lastTimestamp: calls.at(-1)!.timestamp, + totalCostUSD, + totalInputTokens: calls.reduce((s, c) => s + c.usage.inputTokens, 0), + totalOutputTokens: calls.reduce((s, c) => s + c.usage.outputTokens, 0), + totalCacheReadTokens: calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0), + totalCacheWriteTokens: calls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0), + apiCalls: calls.length, + turns: [{ + userMessage: 'hi', + timestamp, + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: calls, + }], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + } +} + +/// A real IANA zone guaranteed to differ from the machine's current one, so the +/// seeded cache reads as a genuine tz change. Kiritimati (UTC+14) differs from +/// every other zone; if the machine itself is Kiritimati, Pago Pago (UTC-11) is +/// 25h away, so a straddling timestamp still exists. +function otherTz(): string { + return currentTzKey() === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati' +} + +/// A 2026-06-13 UTC timestamp that lands on DIFFERENT calendar days under the +/// machine's local tz and `tz` (i.e. a turn that migrates across local midnight +/// when the timezone changes). Deterministic for any machine; two zones with +/// different UTC offsets always have a straddle somewhere in the day. +function straddlingTimestamp(tz: string): string { + for (let h = 0; h < 24; h++) { + const iso = `2026-06-13T${String(h).padStart(2, '0')}:30:00.000Z` + if (dateKey(iso) !== dateKeyInTz(iso, tz)) return iso + } + throw new Error(`no straddling timestamp between local tz and ${tz}`) +} + +/// The production-shaped tz-aware aggregator: re-aggregate under an explicit tz. +function aggregateInTz(projects: ProjectSummary[], tz: string): DailyEntry[] { + return aggregateProjectsIntoDays(projects, (iso) => dateKeyInTz(iso, tz)) +} + +const OLD_TZ = otherTz() +// A fixed day whose sources are entirely gone (no fixture turn buckets to it +// under either tz): the issue #770 "sources-gone day" that must survive. +const GONE_DAY = '2026-06-10' + +async function seed(days: DailyEntry[], overrides: Partial = {}): Promise { + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: OLD_TZ, + lastComputedDate: '2026-06-13', + days, + complete: true, + watermarkTrusted: true, + ...overrides, + }) +} + +/// A real IANA zone guaranteed to be BEHIND the machine's local timezone, so a +/// call early in the NEW tz's today is still the OLD tz's YESTERDAY - the +/// boundary-day direction the history parse range excludes (its calls fall past +/// yesterdayEnd). Etc/GMT+N == UTC-N; pick one ~6h behind so a straddling gap +/// timestamp always exists inside the fake-time window. +function behindTz(): string { + const offsetHours = -new Date().getTimezoneOffset() / 60 + const gmtIndex = Math.max(-12, Math.min(12, 6 - offsetHours)) + return `Etc/GMT${gmtIndex < 0 ? '-' : '+'}${Math.abs(gmtIndex)}` +} + +/// A timestamp in the re-derive's GAP: dated TODAY under the new tz (so the +/// history parse through yesterday excludes it) but YESTERDAY under `tz` (so +/// the baseline cache holds it), and still <= the fake `now` (so a parse +/// through now includes it). +function gapTimestamp(tz: string): { ts: string; oldDate: string } { + const now = new Date() + const todayStr = toDateString(now) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + for (let h = 0; h <= now.getUTCHours(); h++) { + const iso = `2026-06-15T${String(h).padStart(2, '0')}:00:00.000Z` + if (dateKey(iso) !== todayStr) continue + const oldDate = dateKeyInTz(iso, tz) + if (oldDate === yesterdayStr) return { ts: iso, oldDate } + } + throw new Error(`no gap timestamp for ${tz} (today=${todayStr} yesterday=${yesterdayStr})`) +} + +/// A parse mock that RESPECTS its range: calls whose timestamps fall outside +/// [start, end] are dropped. The real parser slices straddling turns per range; +/// this keeps the test's assertion that the boundary call is excluded from a +/// history-only parse honest. +function rangeAwareParse(projects: ProjectSummary[]) { + return async (range: DateRange): Promise => { + const startMs = range.start.getTime() + const endMs = range.end.getTime() + const inRange: ProjectSummary[] = [] + for (const p of projects) { + const sessions = p.sessions + .map(s => ({ + ...s, + turns: s.turns + .map(t => ({ + ...t, + assistantCalls: t.assistantCalls.filter(c => { + const ms = new Date(c.timestamp).getTime() + return ms >= startMs && ms <= endMs + }), + })) + .filter(t => t.assistantCalls.length > 0), + })) + .filter(s => s.turns.length > 0) + if (sessions.length > 0) inRange.push({ ...p, sessions }) + } + return inRange + } +} + +describe('dateKeyInTz', () => { + it('buckets a timestamp under an explicit timezone (machine tz irrelevant)', () => { + // 23:30Z on 06-13 is still 06-13 in New York (19:30 EDT) but already + // 06-14 in Kiritimati (01:30, UTC+14). + expect(dateKeyInTz('2026-06-13T23:30:00.000Z', 'America/New_York')).toBe('2026-06-13') + expect(dateKeyInTz('2026-06-13T23:30:00.000Z', 'Pacific/Kiritimati')).toBe('2026-06-14') + }) +}) + +describe('tz-change re-derive: subtract what the fresh parse re-bucketed (issue #770)', () => { + it('(a) a turn that migrated across local midnight counts once, not twice', async () => { + const ts = straddlingTimestamp(OLD_TZ) + const oldDay = dateKeyInTz(ts, OLD_TZ) + const newDay = dateKey(ts) + expect(newDay).not.toBe(oldDay) + + const fixture = [makeProject([makeCall(ts, 10)])] + await seed([day(oldDay, { codex: slice(10, 1) })]) + + let parseCalls = 0 + const out = await ensureCacheHydrated( + async () => { parseCalls += 1; return fixture }, + aggregateProjectsIntoDays, + 'cfg-A', + () => true, + aggregateInTz, + ) + + // The history parse was aggregated twice (current tz + old tz); the fix + // round 1 subtraction adds a second through-now parse scoped to the + // subtraction, so the tz path parses twice total. + expect(parseCalls).toBe(2) + const total = out.days.reduce((s, d) => s + d.cost, 0) + const codexTotal = out.days.reduce((s, d) => s + (d.providers['codex']?.cost ?? 0), 0) + expect(total).toBeCloseTo(10, 5) + expect(codexTotal).toBeCloseTo(10, 5) + // The old day is fully explained away (its only turn migrated) → dropped. + expect(out.days.find(d => d.date === oldDay)).toBeUndefined() + const newDayEntry = out.days.find(d => d.date === newDay) + expect(newDayEntry).toBeDefined() + expect(newDayEntry!.providers['codex']!.cost).toBeCloseTo(10, 5) + }) + + it('(b) a sources-gone day survives a tz re-derive unchanged', async () => { + const ts = straddlingTimestamp(OLD_TZ) + const oldDay = dateKeyInTz(ts, OLD_TZ) + const newDay = dateKey(ts) + + const fixture = [makeProject([makeCall(ts, 10)])] + await seed([ + day(GONE_DAY, { claude: slice(399.70, 1572) }), + day(oldDay, { codex: slice(10, 1) }), + ]) + + const out = await ensureCacheHydrated( + async () => fixture, + aggregateProjectsIntoDays, + 'cfg-A', + () => true, + aggregateInTz, + ) + + // The vanished-source day is untouched, carried exactly as before. + const gone = out.days.find(d => d.date === GONE_DAY) + expect(gone).toMatchObject({ cost: 399.70, calls: 1572, carried: true }) + expect(gone!.providers['claude']!.cost).toBe(399.70) + // The migrated turn left its old day entirely; it now lives on newDay only. + expect(out.days.find(d => d.date === oldDay)).toBeUndefined() + const newDayEntry = out.days.find(d => d.date === newDay) + expect(newDayEntry!.providers['codex']!.cost).toBeCloseTo(10, 5) + const total = out.days.reduce((s, d) => s + d.cost, 0) + expect(total).toBeCloseTo(399.70 + 10, 5) + }) + + it('(c) a mixed slice subtracts only the migrated part; the remainder is carried', async () => { + const ts = straddlingTimestamp(OLD_TZ) + const oldDay = dateKeyInTz(ts, OLD_TZ) + const newDay = dateKey(ts) + + // Baseline day holds TWO codex turns' worth (20): one is the live turn that + // migrates to newDay, the other's source is gone. Only the live 10 is + // subtracted; the sources-gone 10 is carried forward. + const fixture = [makeProject([makeCall(ts, 10)])] + await seed([day(oldDay, { codex: slice(20, 2) })]) + + const out = await ensureCacheHydrated( + async () => fixture, + aggregateProjectsIntoDays, + 'cfg-A', + () => true, + aggregateInTz, + ) + + const carried = out.days.find(d => d.date === oldDay) + expect(carried).toBeDefined() + expect(carried!.carried).toBe(true) + expect(carried!.providers['codex']!.cost).toBeCloseTo(10, 5) + expect(carried!.providers['codex']!.calls).toBe(1) + const migrated = out.days.find(d => d.date === newDay) + expect(migrated!.providers['codex']!.cost).toBeCloseTo(10, 5) + const total = out.days.reduce((s, d) => s + d.cost, 0) + expect(total).toBeCloseTo(20, 5) + }) + + it('(d) a non-tz re-derive (savings-hash change) preserves a mid-range source hole exactly', async () => { + // No tz change: seed under the machine's own tz. A savings-hash change + // re-derives; the mid-range hole (codex sources gone) must carry at exactly + // 50, byte-identical to the pre-fix behavior. + const fixture = [makeProject([makeCall('2026-06-12T10:00:00.000Z', 100, 'claude')])] + const aggregateToJune12 = (projects: ProjectSummary[]): DailyEntry[] => + aggregateProjectsIntoDays(projects, () => '2026-06-12') + const unexpectedTzAggregation = (): DailyEntry[] => { + throw new Error('aggregateDaysInTz must not be called on a non-tz re-derive') + } + await seed( + [day('2026-06-12', { claude: slice(100, 100), codex: slice(50, 50) })], + { tzKey: currentTzKey() }, + ) + + const out = await ensureCacheHydrated( + async () => fixture, + aggregateToJune12, + 'cfg-B', + () => true, + unexpectedTzAggregation, + ) + + expect(out.savingsConfigHash).toBe('cfg-B') + const kept = out.days.find(d => d.date === '2026-06-12')! + expect(kept.providers['claude']!.cost).toBe(100) + expect(kept.providers['codex']!.cost).toBe(50) + expect(kept.cost).toBeCloseTo(150, 5) + expect(kept.carried).toBe(true) + }) + + it('(e) tzChanged AND savingsConfigHash changed together: no subtraction', async () => { + const ts = straddlingTimestamp(OLD_TZ) + const oldDay = dateKeyInTz(ts, OLD_TZ) + const newDay = dateKey(ts) + + const fixture = [makeProject([makeCall(ts, 10)])] + await seed([day(oldDay, { codex: slice(10, 1) })]) + + const out = await ensureCacheHydrated( + async () => fixture, + aggregateProjectsIntoDays, + 'cfg-B', // hash changed in the same re-derive + () => true, + aggregateInTz, + ) + + // Re-pricing drift must not masquerade as re-bucketing spend: the carry is + // unchanged (the double count stays, exactly as on main today). + const carried = out.days.find(d => d.date === oldDay) + expect(carried).toBeDefined() + expect(carried!.providers['codex']!.cost).toBeCloseTo(10, 5) + const migrated = out.days.find(d => d.date === newDay) + expect(migrated!.providers['codex']!.cost).toBeCloseTo(10, 5) + const total = out.days.reduce((s, d) => s + d.cost, 0) + expect(total).toBeCloseTo(20, 5) + }) +}) + +describe('fix round 1', () => { + it('(f) a call that re-buckets to TODAY (past the history parse) is subtracted from its old day', async () => { + // The boundary-day direction the history parse misses: OLD_TZ is BEHIND the + // machine, so a call early in NEW-tz today is still OLD-tz YESTERDAY - a + // date the baseline cache holds. The re-derive parse used to stop at + // yesterdayEnd, which is BEFORE this call's timestamp, so the old-tz + // re-aggregation never saw it: the baseline slice was carried un-subtracted + // while today's live parse counted it again. The fix parses through NOW for + // the subtraction; the merged cache still stops at yesterday. + const oldTz = behindTz() + const { ts, oldDate } = gapTimestamp(oldTz) + const now = new Date() + const todayStr = toDateString(now) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + expect(dateKey(ts)).toBe(todayStr) + expect(dateKeyInTz(ts, oldTz)).toBe(oldDate) + + const fixture = [makeProject([makeCall(ts, 10)])] + await seed([day(oldDate, { codex: slice(10, 1) })], { tzKey: oldTz }) + + const out = await ensureCacheHydrated( + rangeAwareParse(fixture), + aggregateProjectsIntoDays, + 'cfg-A', + () => true, + aggregateInTz, + ) + + // The migrated call was explained away from its old day: nothing on oldDate + // is carried to be double-counted by today's live parse. + expect(out.days.find(d => d.date === oldDate)).toBeUndefined() + // The cache still holds ONLY history days - today is not finalized, and the + // watermark did not move. + expect(out.days.some(d => d.date >= todayStr)).toBe(false) + expect(out.lastComputedDate).toBe(yesterdayStr) + expect(out.days.reduce((s, d) => s + d.cost, 0)).toBeCloseTo(0, 5) + }) + + it('(g) subtraction residual sessions ADD to a fresh sessions-only placeholder (source-gone sessions survive)', () => { + // A fresh day carries a sessions-only placeholder (sessions=1, cost=0) for a + // session that started on that day; the baseline slice held TWO sessions (that + // one plus a source-gone one). The tz subtraction removes the fresh-explained + // session from the carried slice, leaving a residual of sessions=1. The + // placeholder max-dedup clamps max(1, 1) = 1, permanently dropping the + // source-gone session; the residual must ADD instead. + const fresh = day('2026-06-13', { codex: slice(0, 0, { sessions: 1 }) }, { sessions: 1 }) + const baseline = day('2026-06-13', { codex: slice(0, 0, { sessions: 2 }) }, { sessions: 2 }) + const subtract = new Map>([ + ['2026-06-13', new Map([['codex', { sessions: 1, cost: 0, calls: 0 }]])], + ]) + const merged = mergeDayEntries([fresh], [baseline], true, subtract) + const m = merged[0]! + expect(m.providers['codex']!.sessions).toBe(2) + expect(m.sessions).toBe(2) + }) + + it('(h) day totals subtract the EFFECTIVE removal, not the raw sub (skew)', () => { + // Skew: the fresh-old-tz content for provider A (cost 10) EXCEEDS what the + // cached baseline slice holds (cost 5). The slice clamps to zero, so the day + // loses exactly 5 - NOT 10, which would eat provider B's carried history at + // the day level and leave the day total failing to sum to its surviving + // slices (2 with B still 7). + const a = slice(5, 1, { + models: { 'shared-model': { calls: 1, cost: 5, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + }) + const b = slice(7, 1, { + models: { 'shared-model': { calls: 1, cost: 7, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + }) + const baseline = day('2026-06-13', { A: a, B: b }, { + models: { + 'shared-model': { calls: 2, cost: 12, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + }) + const subtract = new Map>([ + ['2026-06-13', new Map([ + ['A', slice(10, 1, { + models: { 'shared-model': { calls: 1, cost: 10, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + })], + // A subtraction entry for a provider the day does not have must be a + // no-op (effective removal is zero) - it cannot eat day totals. + ['C', slice(999, 99)], + ])], + ]) + const merged = mergeDayEntries([], [baseline], true, subtract) + const m = merged[0]! + // Day totals equal the surviving slice (B): 7, not 2 (12 - raw 10). + expect(m.cost).toBeCloseTo(7, 5) + expect(m.calls).toBe(1) + expect(m.providers['A']).toBeUndefined() + expect(m.providers['C']).toBeUndefined() + expect(m.providers['B']).toMatchObject({ cost: 7, calls: 1 }) + // The day-level model split lost only A's effective share, not B's. + expect(m.models['shared-model']!.cost).toBeCloseTo(7, 5) + expect(m.models['shared-model']!.calls).toBe(1) + // Reconciliation: day totals equal the sum of the surviving slices. + expect(m.cost).toBeCloseTo(m.providers['B']!.cost, 5) + }) +})