diff --git a/components/ads/media-split-card.tsx b/components/ads/media-split-card.tsx index febe7ae..5e90226 100644 --- a/components/ads/media-split-card.tsx +++ b/components/ads/media-split-card.tsx @@ -2,6 +2,7 @@ import { attributedClicks, ctrReadable, ctrUnreadableNote, + FIXED_FORMAT, rotatedImpressions, UNATTRIBUTED, type MediaSplitRow, @@ -27,6 +28,10 @@ const LABELS: Record = { gif: { name: "Animated", hint: "The rendered GIF at the slot's exact size." }, video: { name: "In-banner video", hint: "Muted, looping MP4 with the CTA beneath." }, audio: { name: "Audible companion", hint: "The unit plus a click-to-play control." }, + [FIXED_FORMAT]: { + name: "Fixed format", + hint: "Feed items, text links and terminal units. One presentation, nothing to rotate. Excluded from every share and rate.", + }, [UNATTRIBUTED]: { name: "Unattributed", hint: "Served before the rotation shipped, or a click whose impression cannot be resolved. Excluded from every share and rate.", @@ -41,11 +46,11 @@ export function MediaSplitCard({ rangeHint?: string; }) { const delivery = rotatedImpressions(rows); - const unattributed = rows.find((r) => !r.rotated); + const context = rows.some((r) => !r.rotated && r.impressions + r.clicks + r.freeClicks > 0); // Nothing rotated in this window. The card would be a header over an empty // table, and the tiles above already say whether there was any delivery. - if (delivery === 0 && !unattributed) return null; + if (delivery === 0 && !context) return null; const showCtr = ctrReadable(rows); const note = ctrUnreadableNote(rows); diff --git a/lib/ads/media-stats.ts b/lib/ads/media-stats.ts index db52f7b..bff1fc2 100644 --- a/lib/ads/media-stats.ts +++ b/lib/ads/media-stats.ts @@ -27,6 +27,16 @@ import { AD_MEDIA_KINDS, type AdMediaKind } from "./media"; */ export const UNATTRIBUTED = "unknown"; +/** + * The bucket for fills whose format has one presentation and nothing to rotate: + * feed items, text links, terminal units (see rotatesMedia). They serve as + * 'static' by construction, and they are most of the network's delivery, so + * booking them as the static arm made static win an arm it could not lose + * ("Static 98.7%" while a 300x250 split 46% static). Reported, never shared or + * rated. Bucketed in SQL by ad_media_bucket. + */ +export const FIXED_FORMAT = "fixed"; + export type MediaSplitRow = { media: string; /** True for the five real arms; false for UNATTRIBUTED. */ @@ -40,7 +50,9 @@ export type MediaSplitRow = { spentCents: number; /** Share of rotated delivery, 0-1. Zero for UNATTRIBUTED. */ share: number; - /** clicks / impressions, or null when there is nothing to divide. */ + /** clicks / impressions for a rotated arm; null when there is nothing to + * divide, and always null for FIXED_FORMAT and UNATTRIBUTED, which are + * context rather than arms and would otherwise show a crawler's CTR. */ ctr: number | null; }; @@ -77,9 +89,10 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] { const clicks = n(r.clicks); const freeClicks = n(r.free_clicks); const impressions = paidImpressions + freeImpressions; + const rotated = isRotated(media); return { media, - rotated: isRotated(media), + rotated, impressions, paidImpressions, freeImpressions, @@ -89,7 +102,7 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] { share: 0, // Null, not 0: "nobody clicked this" and "nothing was measured" are // different findings and only one of them is about the medium. - ctr: impressions > 0 ? (clicks + freeClicks) / impressions : null, + ctr: rotated && impressions > 0 ? (clicks + freeClicks) / impressions : null, }; }); @@ -103,12 +116,11 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] { row.share = row.rotated && rotatedTotal > 0 ? row.impressions / rotatedTotal : 0; } - // Biggest arm first, and the unattributed bucket always last — it is context, - // not a competitor. - return mapped.sort((a, b) => { - if (a.rotated !== b.rotated) return a.rotated ? -1 : 1; - return b.impressions - a.impressions; - }); + // Biggest arm first, then fixed-format delivery, and the unattributed bucket + // always last — both are context, not competitors. + const rank = (r: { rotated: boolean; media: string }) => + r.rotated ? 0 : r.media === FIXED_FORMAT ? 1 : 2; + return mapped.sort((a, b) => rank(a) - rank(b) || b.impressions - a.impressions); } /** Rotated delivery in the window — the denominator, and whether there is one. */ diff --git a/supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql b/supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql new file mode 100644 index 0000000..1521e32 --- /dev/null +++ b/supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql @@ -0,0 +1,168 @@ +-- Delivery by medium: take the formats that cannot rotate out of the experiment. +-- +-- 20260925150000 booked every fill under the medium it was served as, and a +-- feed item, a text link and a terminal unit are always served as 'static' — +-- they have exactly one presentation (mediaKindsForFormat in lib/ads/media.ts). +-- Those formats are ~95% of the network's fills, so the card read +-- "Static 98.7%" while a 300x250 was actually splitting 46% static, which is +-- what a pool with ~40% of campaigns rendered should produce. Static was +-- winning an arm it could not lose. +-- +-- The clicks went the same way: every click in the window is invalid, and the +-- bulk are crawlers replaying feed-item links from impressions days to weeks +-- old. Counted as 'static' (or as 'unknown' when the impression predates the +-- media column) they lifted the table over the 30-click gate and put a CTR +-- column on five arms that had six banner clicks between them. +-- +-- Fix: a fourth bucket, 'fixed', for any fill whose creative's format does not +-- rotate. It is reported and never shared or rated, like 'unknown'. The format +-- comes from the creative (impressions and clicks both carry creative_id), so a +-- click whose impression was deleted still lands in the right bucket. +-- +-- The rotating formats are listed here by hand; tests/contract/ +-- ads-media-split-fixed-formats pins the list to rotatesMedia(). + +create or replace function public.ad_media_bucket(p_format text, p_media text) +returns text +language sql +immutable +as $$ + select case + when p_format in ('banner_300x250', 'banner_728x90', 'banner_320x50') + then coalesce(p_media, 'unknown') + else 'fixed' + end; +$$; + +-- Refresh. Now DELETES the window before re-inserting: an upsert only touches +-- the keys it produces, so a (owner, day, 'static') row computed under the old +-- bucketing would otherwise sit beside the new 'fixed' row forever. +create or replace function public.ad_stats_media_rollup_refresh( + p_from timestamptz default (now() - interval '2 days') +) returns void +language plpgsql +security definer +set search_path to 'public' +as $fn$ +declare + v_from_day timestamptz := date_trunc('day', p_from at time zone 'UTC') at time zone 'UTC'; +begin + delete from public.ad_stats_owner_media_daily + where day >= (v_from_day at time zone 'UTC')::date; + + insert into public.ad_stats_owner_media_daily as t + (owner_id, day, media, paid_impressions, free_impressions, + valid_clicks, free_clicks, spent_cents) + select c.owner_id, + (e.ts at time zone 'UTC')::date, + e.media, + sum(e.paid)::bigint, sum(e.free)::bigint, sum(e.clk)::bigint, + sum(e.fclk)::bigint, sum(e.spent)::bigint + from ( + select i.campaign_id, i.ts, public.ad_media_bucket(cr.format, i.media) as media, + case when i.tier = 'free' then 0 else 1 end as paid, + case when i.tier = 'free' then 1 else 0 end as free, + 0 as clk, 0 as fclk, 0 as spent + from public.ad_impressions i + join public.ad_creatives cr on cr.id = i.creative_id + where not i.duplicate and i.ts >= v_from_day + union all + select cl.campaign_id, cl.ts, public.ad_media_bucket(cr.format, i.media), + 0, 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.charged_cents, 0) else 0 end + from public.ad_clicks cl + join public.ad_creatives cr on cr.id = cl.creative_id + left join public.ad_impressions i on i.id = cl.impression_id + where cl.ts >= v_from_day + ) e + join public.ad_campaigns c on c.id = e.campaign_id + group by 1, 2, 3 + on conflict (owner_id, day, media) do update set + paid_impressions = excluded.paid_impressions, + free_impressions = excluded.free_impressions, + valid_clicks = excluded.valid_clicks, + free_clicks = excluded.free_clicks, + spent_cents = excluded.spent_cents; +end; +$fn$; + +revoke all on function public.ad_stats_media_rollup_refresh(timestamptz) from public; + +-- The read: unchanged apart from the live edge using the same bucketing. +create or replace function public.ad_owner_media_split( + p_since timestamptz default null +) returns table( + media text, impressions bigint, free_impressions bigint, + clicks bigint, free_clicks bigint, spent_cents bigint +) +language plpgsql +stable +security definer +set search_path to 'public' +as $$ +declare + uid uuid := auth.uid(); + today_start timestamptz := date_trunc('day', now() at time zone 'UTC') at time zone 'UTC'; + first_full_day date; +begin + if uid is null then + return; + end if; + + first_full_day := case + when p_since is null then null + when p_since = date_trunc('day', p_since at time zone 'UTC') at time zone 'UTC' + then (p_since at time zone 'UTC')::date + else ((p_since at time zone 'UTC')::date + 1) + end; + + return query + with ev as ( + select r.media, r.paid_impressions as paid, r.free_impressions as free, + r.valid_clicks as clk, r.free_clicks as fclk, r.spent_cents as spent + from public.ad_stats_owner_media_daily r + where r.owner_id = uid + and r.day < (today_start at time zone 'UTC')::date + and (first_full_day is null or r.day >= first_full_day) + union all + select public.ad_media_bucket(cr.format, i.media), + case when i.tier = 'free' then 0 else 1 end, + case when i.tier = 'free' then 1 else 0 end, + 0, 0, 0 + from public.ad_impressions i + join public.ad_campaigns c on c.id = i.campaign_id and c.owner_id = uid + join public.ad_creatives cr on cr.id = i.creative_id + where not i.duplicate + and i.ts >= greatest(today_start, coalesce(p_since, today_start)) + and (p_since is null or i.ts >= p_since) + union all + select public.ad_media_bucket(cr.format, i.media), 0, 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.charged_cents, 0) else 0 end + from public.ad_clicks cl + join public.ad_campaigns c on c.id = cl.campaign_id and c.owner_id = uid + join public.ad_creatives cr on cr.id = cl.creative_id + left join public.ad_impressions i on i.id = cl.impression_id + where cl.ts >= greatest(today_start, coalesce(p_since, today_start)) + and (p_since is null or cl.ts >= p_since) + ) + select ev.media, sum(ev.paid)::bigint, sum(ev.free)::bigint, + sum(ev.clk)::bigint, sum(ev.fclk)::bigint, sum(ev.spent)::bigint + from ev group by ev.media order by (sum(ev.paid) + sum(ev.free)) desc; +end; +$$; + +revoke all on function public.ad_owner_media_split(timestamptz) from public; +grant execute on function public.ad_owner_media_split(timestamptz) to authenticated; + +-- Rebuild the whole rollup under the new bucketing, from the oldest raw event +-- rather than from the rollup's own first day: 20260925150000 only filled the +-- two days before it shipped, so a "last 30 days" card was reading one week. +select public.ad_stats_media_rollup_refresh( + least((select min(ts) from public.ad_impressions), + (select min(ts) from public.ad_clicks), + now() - interval '2 days') +); diff --git a/tests/ads-media-stats.test.ts b/tests/ads-media-stats.test.ts index c490dda..0f9db29 100644 --- a/tests/ads-media-stats.test.ts +++ b/tests/ads-media-stats.test.ts @@ -7,6 +7,7 @@ import { rotatedImpressions, MIN_CLICKS_TO_COMPARE, UNATTRIBUTED, + FIXED_FORMAT, } from "@/lib/ads/media-stats"; /** An RPC row, with the zeros the RPC would actually send. */ @@ -32,6 +33,40 @@ function row( } describe("shaping the split", () => { + it("keeps fixed-format delivery out of the static arm's share", () => { + // The production shape on 2026-09-28: feed items are ~95% of fills and can + // only render static. Counted as the static arm they read "Static 98.7%". + const rows = mediaSplitRows([ + row("static", { free_impressions: 46 }), + row("image", { free_impressions: 30 }), + row("gif", { free_impressions: 24 }), + row(FIXED_FORMAT, { free_impressions: 190_000, free_clicks: 2_000 }), + ]); + const byMedia = Object.fromEntries(rows.map((r) => [r.media, r])); + expect(byMedia.static.share).toBeCloseTo(0.46); + expect(byMedia[FIXED_FORMAT].rotated).toBe(false); + expect(byMedia[FIXED_FORMAT].share).toBe(0); + // A crawler replaying feed-item links is not a rate for anything... + expect(byMedia[FIXED_FORMAT].ctr).toBeNull(); + // ...and its clicks do not open the CTR gate for the real arms. + expect(ctrReadable(rows)).toBe(false); + }); + + it("gives the unattributed bucket no rate", () => { + const [r] = mediaSplitRows([row(UNATTRIBUTED, { free_impressions: 100, free_clicks: 8 })]); + expect(r.ctr).toBeNull(); + }); + + it("orders arms, then fixed format, then unattributed", () => { + const rows = mediaSplitRows([ + row(UNATTRIBUTED, { free_impressions: 9_000 }), + row(FIXED_FORMAT, { free_impressions: 90_000 }), + row("gif", { free_impressions: 5 }), + row("static", { free_impressions: 10 }), + ]); + expect(rows.map((r) => r.media)).toEqual(["static", "gif", FIXED_FORMAT, UNATTRIBUTED]); + }); + it("counts paid and free together, because a mix describes what was shown", () => { // Every fill on this network books free, so a paid-only impressions figure // would report the whole card as zeros — the #199 bug, one surface later. diff --git a/tests/contract/ads-media-split-fixed-formats.test.ts b/tests/contract/ads-media-split-fixed-formats.test.ts new file mode 100644 index 0000000..57da5da --- /dev/null +++ b/tests/contract/ads-media-split-fixed-formats.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { AD_FORMAT_IDS } from "@/lib/ads/formats"; +import { rotatesMedia } from "@/lib/ads/media"; + +// ad_media_bucket (20260928120000) names the formats that rotate by hand, in +// SQL, because the rollup cannot call TypeScript. If a format gains a second +// presentation in MEDIA_BY_FORMAT and the SQL is not updated, every fill of it +// is booked as 'fixed' and the rotation it now runs is invisible — or, the +// other way round, a one-presentation format lands in the static arm and makes +// it read ~99% again. Pin the two lists together. + +const MIGRATION = join( + process.cwd(), + "supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql", +); + +describe("ad_media_bucket's rotating formats", () => { + it("are exactly the formats rotatesMedia() says rotate", () => { + const sql = readFileSync(MIGRATION, "utf8"); + const m = sql.match(/p_format in \(([^)]*)\)/); + expect(m).not.toBeNull(); + const inSql = [...m![1].matchAll(/'([^']+)'/g)].map((x) => x[1]).sort(); + const rotating = AD_FORMAT_IDS.filter(rotatesMedia).sort(); + expect(inSql).toEqual(rotating); + }); +});