Merge pull request #906 from ozymandiashh/fix/770-tz-carry-dedup

fix(daily-cache): surgical tz-migration de-dup for carried days (#770)
This commit is contained in:
Resham Joshi 2026-08-10 02:30:12 -07:00 committed by GitHub
commit c6548fc96f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 858 additions and 14 deletions

View file

@ -526,19 +526,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
@ -578,7 +591,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)
@ -588,7 +601,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) })
}
@ -604,6 +621,246 @@ function setOwn<T>(target: Record<string, T>, 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<string, ReadonlyMap<string, ProviderDaySlice>> {
const byDate = new Map<string, Map<string, ProviderDaySlice>>()
for (const day of days) {
if (Object.keys(day.providers).length === 0) continue
const byProvider = new Map<string, ProviderDaySlice>()
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
@ -619,13 +876,36 @@ function setOwn<T>(target: Record<string, T>, 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<string, ReadonlyMap<string, ProviderDaySlice>>,
): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
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
@ -637,7 +917,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
}
}
@ -685,6 +980,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<DailyCache> {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
@ -750,16 +1051,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<string, ReadonlyMap<string, ProviderDaySlice>> | 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,

View file

@ -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<string, DailyEntry>()
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

View file

@ -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<DailyCache> {
// 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

View file

@ -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> = {}): ProviderDaySlice {
return { cost, calls, savingsUSD: 0, ...extra }
}
function day(date: string, providers: Record<string, ProviderDaySlice>, overrides: Partial<DailyEntry> = {}): 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<typeof makeCall>[]): 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<DailyCache> = {}): Promise<void> {
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<ProjectSummary[]> => {
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<string, Map<string, ProviderDaySlice>>([
['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<string, Map<string, ProviderDaySlice>>([
['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)
})
})