From 75b7df6bdd5eb56ec1131c9f8581faa1d4db1d37 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:01:07 +0300 Subject: [PATCH] fix(optimize): strengthen the result-cache fingerprint against collisions cacheKey fingerprinted only project count + api-call sum, so two datasets agreeing on those two numbers collided onto one cached OptimizeResult, and a cost/token change that left call count unchanged (e.g. a re-price) served stale findings within the 60s TTL - reachable in the long-lived menubar. Fold total cost, savings and proxied cost (scaled to micro-dollars) into the key. Exported cacheKey and mutation-checked: the old key collides two same-shape datasets and a re-price; the new one separates both, while an identical dataset still keys identically. --- src/optimize.ts | 18 ++++++++++++++++-- tests/optimize.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/optimize.ts b/src/optimize.ts index 7900998..121507c 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -2965,9 +2965,23 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' - const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0) + // Fingerprint enough of the dataset that two materially different inputs + // cannot collide onto one cached OptimizeResult. Project count + api-call + // sum alone collided any two datasets sharing those two numbers, and served + // stale findings when cost/tokens moved (e.g. a re-price) while call count + // held - reachable in the long-lived menubar process within the 60s TTL. + // Cost is scaled to whole micro-dollars so float jitter cannot thrash the key. + let calls = 0, cost = 0, savings = 0, proxied = 0 + for (const p of projects) { + calls += p.totalApiCalls + cost += p.totalCostUSD + savings += p.totalSavingsUSD + proxied += p.totalProxiedCostUSD + } + // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. + const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index fdbd492..c3bcdf0 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -22,6 +22,7 @@ import { detectLowWorthSessions, detectSessionOutliers, scanAndDetect, + cacheKey, computeHealth, computeTrend, buildOptimizeJsonReport, @@ -1041,6 +1042,34 @@ describe('detectSessionOutliers', () => { }) }) +describe('optimize cacheKey collision resistance', () => { + it('does not collide two datasets that share project count and api-call sum', () => { + // The old fingerprint was projectCount + sum(api calls) only, so any two + // datasets agreeing on those two numbers shared one cached OptimizeResult - + // the second scan got the first's findings. Same shape, different spend must + // now key differently. + const a = projectWithSessions([100, 1, 1, 1]) // 4 calls, cost 103 + const b = projectWithSessions([1, 1, 1, 1]) // 4 calls, cost 4 + const range = optimizeDateRange(4) + expect(a.totalApiCalls).toBe(b.totalApiCalls) + expect(cacheKey([a], range)).not.toBe(cacheKey([b], range)) + }) + + it('is stable for the identical dataset (still caches a genuine repeat)', () => { + const a = projectWithSessions([5, 3, 2]) + const range = optimizeDateRange(3) + expect(cacheKey([a], range)).toBe(cacheKey([projectWithSessions([5, 3, 2])], range)) + }) + + it('separates a re-price that leaves call count unchanged', () => { + // A dataset re-priced (cost moves, calls do not) must not serve stale findings. + const before = projectWithSessions([10, 10]) + const after = projectWithSessions([25, 10]) // same 2 calls, higher cost + const range = optimizeDateRange(2) + expect(cacheKey([before], range)).not.toBe(cacheKey([after], range)) + }) +}) + describe('computeHealth', () => { it('returns A with 100 for no findings', () => { const { score, grade } = computeHealth([])