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) + }) +})