diff --git a/lib/ads/fraud.ts b/lib/ads/fraud.ts index a8077ba..4b93212 100644 --- a/lib/ads/fraud.ts +++ b/lib/ads/fraud.ts @@ -30,12 +30,31 @@ function safeId(v: string | null | undefined): string | null { // (the shape used for clicks) collapses none of it. Slot-keyed dedupe collapses // the whole burst to one counted impression. // -// 60s rather than the click path's 6h because a repeat view is a real thing: a -// human reloading an article an hour later genuinely saw the ad twice, and an -// hours-long window would erase legitimate delivery. 60s is long enough to -// swallow a machine-driven burst (the observed one fires 12 fetches in ~3s) and -// short enough that no human pattern lands inside it twice by accident. -export const IMPRESSION_DEDUPE_WINDOW_MS = 60 * 1000; +// 5s rather than the click path's 6h, because a repeat view is a real thing: a +// human reloading an article genuinely saw the ad twice, and a long window +// erases legitimate delivery. +// +// This shipped at 60s, chosen by reasoning about human behaviour. Backtesting +// the rule against 7 days of real impressions showed that was wrong — the +// machine bursts are compressed into seconds, so a wide window costs a great +// deal of real delivery to catch almost nothing extra: +// +// window terminal flagged (target) web flagged (cost) +// 3s 84.5% 12.2% +// 5s 84.6% 14.2% +// 10s 84.7% 17.5% +// 60s 85.4% 36.6% +// +// 60s bought +0.9pp on the target and cost 22pp of real web impressions. 5s +// keeps essentially all of the burst suppression — the observed burst spans +// ~2.5s — with a little margin for a slower run, since each fetch in the loop +// can take up to its own timeout. +// +// The ~12-14% web floor that remains at short windows is not noise: it is the +// same visitor re-fetching the same slot sub-second, which is /ad.js clearing +// data-cp-filled in its .catch() path and SPA callers re-firing scan(). That is +// a real double-count bug and dedupe only masks it. +export const IMPRESSION_DEDUPE_WINDOW_MS = 5 * 1000; export type ClickValidity = { valid: boolean; reason?: string }; diff --git a/tests/contract/ads-impression-dedupe.test.ts b/tests/contract/ads-impression-dedupe.test.ts index d3bea29..fbc5413 100644 --- a/tests/contract/ads-impression-dedupe.test.ts +++ b/tests/contract/ads-impression-dedupe.test.ts @@ -131,8 +131,12 @@ describe("isDuplicateImpression", () => { }); it("uses a much shorter window than the click path", async () => { - // A repeat view hours later is real delivery; only a machine-driven burst - // lands twice inside a minute. - expect(IMPRESSION_DEDUPE_WINDOW_MS).toBe(60_000); + // Tuned against 7 days of real impressions, not intuition. At 60s the rule + // flagged 36.6% of web impressions to gain 0.9pp on the machine bursts it + // targets; at 5s it flags 14.2% of web and still catches 84.6% of terminal, + // because the bursts are compressed into ~2.5s. + expect(IMPRESSION_DEDUPE_WINDOW_MS).toBe(5_000); + // Must stay far below the click window — these measure different things. + expect(IMPRESSION_DEDUPE_WINDOW_MS).toBeLessThan(60_000); }); });