mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 08:22:42 +00:00
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:
commit
c6548fc96f
4 changed files with 858 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
488
tests/daily-cache-tz-dedup.test.ts
Normal file
488
tests/daily-cache-tz-dedup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue