PaddySpeaks · Systems at the Whiteboard · Nº 03

The Auction Problem

Model a search-ads system end to end — a hundred advertisers compete in under a hundred milliseconds, one wins, and the conversion lands four days later on a different device. A complete working through: the causal chain across four facts, the one join key that threads it, append-only attribution you can re-run, loser sampling for the ranker, quality-score calibration, and the dashboard.

§ 01 — THE QUESTIONOne key, four facts, a causal chain

This prompt asks for "an ads model" and is in fact a causal chain stretched across four orders of magnitude and several days — where the hardest requirement is not volume but the ability to re-decide the past without rewriting it.

Interview Prompt

"Design a data model for search ads that captures the whole chain — auction clearing, impression, click, conversion — and supports advertiser billing, quality-score recomputation, ranker training, incrementality measurement, and re-runnable attribution windows. How do you scope it?"

LEVEL · SENIOR / STAFFDURATION · 45 MINFORMAT · WHITEBOARD

The trap is to model the four stages as one event. They are not: they are four facts at violently different cardinalities and tempos. Two billion auctions a day clear in under a hundred milliseconds each; roughly a hundred ads compete in every one of them and exactly one wins, so the losing candidates outnumber the winners twentyfold. Impressions follow auctions, clicks follow impressions at a few percent, conversions follow clicks hours or days later — and the click can be invalidated by bot-detection that runs long after billing thought it was done. Collapse this into one table and the join becomes a timestamp-matching séance; keep it as four and you need one thing above all: a key that is born once and travels the whole way down.

A weak answer fuzzy-joins clicks to conversions on user and time and calls attribution a pipeline. A strong answer notices that attribution is not collected, it is computed — and that the moment a model changes, you must be able to recompute it over the same raw events without losing the old answer. So before any tables, the frame for the session:

THE JOIN KEY
auction_decision_id. Minted once at auction time, stamped onto the impression, carried by the click, threaded to the conversion. No timestamp matching, no fuzzy joins — the chain is one column wide.
THE COUNTERFACTUAL
The losers. Twenty times the winners by volume and worthless to keep whole, but indispensable as a balanced training set for the ranker. Sampled at 1% with a reweighting column, kept honest.
THE RE-RUN
Attribution as a function, not a fact. Each model run is an append-only batch tagged with a run id. Last-click and data-driven live side by side; switching models is a SELECT, never a re-ingest.
Attribution is computed, not collected. The day a model changes you must be able to re-decide every past conversion — and still be able to prove what you decided yesterday.

Scoping out loud

Scope is the first scored dimension, so name it. In scope: the four-fact chain, the loser counterfactual, the append-only attribution layer with versioned models, and the analytics for billing, the ranker, and measurement. Out of scope, said explicitly: the real-time auction engine itself (the sub-100 ms serving path that runs the second-price auction — treated as the producer of fct_auctions), the bidding/budget pacing controller, creative storage, and spam/fraud beyond the invalid-click hook. The caveat: the model must not preclude privacy carve-outs, so a privacy_path is first-class from the start rather than retrofitted.

Then the envelope math, volunteered. Search-ads numbers at planetary scale:

QuantityEstimateConsequence
Ad requests / day2,000,000,000≈ 23 K auctions/s average, ~6× at peak
Candidates per auction~100Sets the raw counterfactual volume before sampling
Loser rows / day (raw)≈ 200 B20× winners — the row that forces sampling, not retention
Click-through rate~3%Clicks are 1/30th of impressions; conversions rarer still
Conversion lagminutes → daysAttribution windows must span 1–28 days; facts arrive late
Invalid-click detection laghours → daysClicks are a slowly-changing fact; billing must be restatable
Attribution re-runson every model shipRe-attribution must be a query, not a re-ingestion

Read the table and the architecture is half-decided. The loser row dictates that the counterfactual is sampled and reweighted, never stored whole. The two lag rows — conversions arriving days late, invalid clicks landing later still — dictate that nothing downstream may assume a fact is final when it lands. And the re-run row dictates the central design move: attribution is a derived, append-only layer keyed by a run id, so the past can be re-decided without being overwritten. The rest of this article follows the key down the chain.


§ 02 — DATA FLOWFollowing one auction down the chain

One key, four facts, two tempos. The serving path mints the decision and races on; the measurement path stitches the chain together hours later and re-runs attribution whenever a model ships — from the same immutable events.

SERVING PATH · <100 MS · ~23K AUCTIONS/S MEASUREMENT PATH · HOURS LATER · RE-RUNNABLE QUERY + CONTEXT user types, page loads AUCTION ENGINE rank ~100 · 2nd-price mint decision_id EVENT LOG key: auction_decision_id winner + sampled losers IMPRESSION SERVED carries decision_id CLICK BEACON later: invalidation LOSER SAMPLER (1%) reservoir + sample_weight CONVERSION PING days later · advertiser CHAIN STITCHER join on decision_id eligible-touchpoint set ATTRIBUTION RUN append · run_id · model_id RANKER TRAINING winners + weighted losers BILLING / ROAS latest run · valid clicks CALIBRATION / QS predicted vs realized pCTR stitch on auction_decision_id SOLID — immutable event writes · DASHED — the re-runnable join · The engine decides once; attribution re-decides forever.
FIG. 1 — The chain and its two tempos. The serving path mints the key and emits immutable facts; the measurement path joins on that key and re-runs attribution as an append-only layer.

Three properties of this picture do most of the interview's work. First, every fact on the serving path is immutable and carries auction_decision_id, so the chain is reconstructed by an equi-join on one column rather than by guessing which click caused which conversion. Second, attribution is a separate plane entirely — the stitcher pairs each conversion with its eligible touchpoints and an attribution run writes credit as new rows, so last-click and data-driven coexist and yesterday's answer is never destroyed. Third, the loser sampler is dashed off to the side because the counterfactual is a sampled tributary, not the main flow: 1% of losers reweighted is a balanced training set; 100% of losers is a bankrupt warehouse.

The Lateness Philosophy, In One Rule

No fact is final when it lands; correctness is a function of when you ask. A conversion can arrive four days after its click, so attribution windows are evaluated at read time against the touchpoints that existed in-window. A click can be invalidated hours after it billed, so invalidation is an appendis_invalid = TRUE with an invalidation_ts — never an update, and billing is restated from the corrected view. The system is eventually consistent by design; its honesty lives in the run id and the timestamp, which let any number be reproduced exactly as it stood on the day it was reported.


§ 03 — DATA MODELFour facts, one key, an append-only attribution layer

The schema falls out of the chain. Four immutable facts threaded by auction_decision_id; a fifth fact, attribution, that is derived and append-only; a sampled sixth for the counterfactual; and versioned dimensions where a model or a creative has a history that billing must reproduce.

The decision and what it served

The auction is the head of the chain. It records the winner, the second-price clearance, and the privacy path — and it mints auction_decision_id, the single most important column in the model. The impression is what actually rendered, joined to the auction on that key, and it carries the ranker's own prediction (predicted_pctr, quality_score, ad_rank) so the ranker can later be graded against reality.

DDL · DECISION + IMPRESSION (IMMUTABLE)
-- Head of the chain. One row per auction clearing. The decision_id -- minted here is stamped onto everything downstream -- the join key. CREATE TABLE fct_auctions ( auction_decision_id TEXT PRIMARY KEY, -- the join key for the chain query_text_hash TEXT NOT NULL, -- hashed, never raw query keyword_id BIGINT REFERENCES dim_keywords, user_token TEXT, -- privacy-tokenized, nullable device_id TEXT, auction_ts TIMESTAMPTZ NOT NULL, -- within ~100ms of the request winning_ad_id BIGINT NOT NULL, clearing_price_local NUMERIC(10,4) NOT NULL, fx_rate_to_usd NUMERIC(12,6) NOT NULL, clearing_price_usd NUMERIC(10,4) NOT NULL, -- second-price clearance privacy_path TEXT NOT NULL DEFAULT 'standard' CHECK (privacy_path IN ('standard','att_optout','sandbox','floc')) ); -- What actually rendered. Joins to auctions on the decision_id. -- Carries the ranker's prediction so we can grade it later (§ 07). CREATE TABLE fct_impressions ( impression_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, auction_decision_id TEXT NOT NULL REFERENCES fct_auctions, creative_id BIGINT NOT NULL REFERENCES dim_creatives, served_ts TIMESTAMPTZ NOT NULL, predicted_pctr NUMERIC(6,5) NOT NULL, -- the ranker's CTR guess quality_score NUMERIC(4,3) NOT NULL, ad_rank NUMERIC(8,5) NOT NULL -- bid x pctr x quality ); CREATE INDEX idx_imp_decision ON fct_impressions (auction_decision_id);

The click and the conversion

The click is the chain's one mutable-by-append fact. It is born clean, and bot-detection may later mark it invalid — but the model never overwrites it. is_invalid flips to TRUE with an invalidation_ts, so the billing history that charged the advertiser yesterday remains reproducible while today's credit is corrected. The conversion is the tail: an advertiser-reported event with a value, arriving hours or days after the click, owning no knowledge of which touchpoint earned it — that is attribution's job.

DDL · CLICK (SLOWLY-CHANGING) + CONVERSION
-- The click. Validation is asynchronous: bot-detection runs for hours -- to days. We APPEND the verdict, never UPDATE -- billing stays auditable. CREATE TABLE fct_clicks ( click_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, impression_id BIGINT NOT NULL REFERENCES fct_impressions, click_ts TIMESTAMPTZ NOT NULL, is_invalid BOOLEAN NOT NULL DEFAULT FALSE, -- set later by detection invalidation_ts TIMESTAMPTZ -- when, and only when, invalid ); CREATE INDEX idx_clk_imp ON fct_clicks (impression_id); -- The conversion. Advertiser-reported, lands days after the click, -- and knows nothing of which touchpoint caused it. Attribution decides. CREATE TABLE fct_conversions ( conversion_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, advertiser_id BIGINT NOT NULL REFERENCES dim_advertisers, conversion_ts TIMESTAMPTZ NOT NULL, conversion_type TEXT NOT NULL, -- purchase | signup | install conversion_value_usd NUMERIC(10,2) NOT NULL DEFAULT 0 );

Attribution as an append-only fact

This is the table that makes the system senior. It is not collected — it is the materialized output of running a model over the chain. One row per (conversion × eligible-touchpoint × model × run), partitioned by attribution_run_id. The same conversion appears under MODEL_LAST_CLICK with credit 1.0 and under MODEL_DDA_v3 with a fractional credit, in the same run, never destroying the other. Re-attributing a quarter when the model upgrades is a new run id over the same immutable events — the old run is preserved, and an advertiser can compare the two side by side.

DDL · ATTRIBUTION (DERIVED · APPEND-ONLY) + LOSERS
-- The crux. NOT ingested -- computed. One row per (conversion x -- touchpoint x model x run). New model = new run_id over the SAME events. -- Old runs are never overwritten: re-attribution is append, not rebuild. CREATE TABLE fct_attributions ( attribution_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, conversion_id BIGINT NOT NULL REFERENCES fct_conversions, impression_id BIGINT, click_id BIGINT, model_id TEXT NOT NULL REFERENCES dim_attribution_models, attribution_run_id TEXT NOT NULL, -- the partition; latest wins on read window_type TEXT NOT NULL, -- click_24h, click_7d, view_1d ... fractional_credit NUMERIC(5,4) NOT NULL, -- 1.0 last-click; <1 distributed attributed_value_usd NUMERIC(10,2) NOT NULL ); CREATE INDEX idx_attr_run ON fct_attributions (attribution_run_id, model_id); -- The counterfactual. 20x the winners raw -- reservoir-sampled to 1%. -- sample_weight = 1/rate reconstitutes the population in training. CREATE TABLE fct_auction_losers ( auction_decision_id TEXT NOT NULL REFERENCES fct_auctions, candidate_ad_id BIGINT NOT NULL, ad_rank NUMERIC(8,5) NOT NULL, sample_weight INT NOT NULL DEFAULT 100 -- 1 / 0.01 );

The dimensions are conformed and mostly SCD2, because the chain spans days and budgets drift hourly. dim_advertisers, dim_campaigns, dim_ad_groups, and dim_creatives all version their attributes so a quality-score regression can be debugged against the creative that was live at impression time, not the one live today. dim_keywords is SCD1 and high-cardinality — its history is not worth the storage. dim_attribution_models is SCD2 and is the keystone of re-attribution: the model is a versioned row, so "last-click vs data-driven" is a join, not an ETL fork. And dim_users is privacy-tokenized — never a raw user id, only a token and a privacy path.


§ 04 — THE CORE INVARIANTWhere the credit is conserved

The correctness of the whole model lives in two rules. The join rule: the chain is reconstructed only by auction_decision_id, never by timestamp proximity. The conservation rule: within a single run and model, the fractional credits for a conversion sum to one.

Attribution is dangerous because credit is real money divided among contenders. Give a conversion's full value to two touchpoints and you have double-paid; lose a fraction and an advertiser's ROAS is understated. The guard is arithmetic and per-conversion: across the eligible touchpoints for one conversion under one model in one run, fractional_credit sums to exactly 1.0 (or to 0.0 when the conversion falls in no window — unattributed, which is itself a valid, recorded answer). This is the auction's version of a ledger that must balance — RULE Nº 1, made executable.

The chain as a state lifecycle

AUCTION_CLEARED IMPRESSION CLICK CONVERSION ATTRIBUTED

Two edges branch off this spine. From CLICK, an INVALIDATED edge appends a verdict without rewinding billing. From CONVERSION, a re-run re-enters ATTRIBUTED under a new attribution_run_id — the only state that is allowed to happen more than once, because re-deciding the past is the entire point. Every other transition is immutable and append-once.

SQL · THE ATTRIBUTION RUN — CREDIT SUMS TO ONE
-- Materialize one attribution run. Pair each conversion with its -- eligible in-window touchpoints, distribute credit per the model, and -- ASSERT the credits sum to 1 per conversion before the run is sealed. INSERT INTO fct_attributions (conversion_id, impression_id, click_id, model_id, attribution_run_id, window_type, fractional_credit, attributed_value_usd) SELECT cv.conversion_id, tp.impression_id, tp.click_id, :model_id, :run_id, tp.window_type, tp.credit, round(cv.conversion_value_usd * tp.credit, 2) FROM fct_conversions cv JOIN LATERAL eligible_touchpoints(cv.conversion_id, :model_id) tp ON TRUE WHERE cv.conversion_ts >= :window_start; -- the invariant: per conversion, credit sums to 1 (or 0 = unattributed). DO $$ DECLARE bad INT; BEGIN SELECT count(*) INTO bad FROM ( SELECT conversion_id, sum(fractional_credit) AS c FROM fct_attributions WHERE attribution_run_id = :run_id AND model_id = :model_id GROUP BY conversion_id HAVING abs(sum(fractional_credit) - 1.0) > 0.001 AND sum(fractional_credit) > 0.0 -- 0 = legitimately unattributed ) q; IF bad > 0 THEN RAISE EXCEPTION 'run % model %: % conversions do not sum to 1', :run_id, :model_id, bad; END IF; END $$;
A conversion's credit sums to one, across every touchpoint that earned it. The model decides the split; the invariant decides whether the run is allowed to exist.ATTRIBUTION RULE Nº 1 — CREDIT IS CONSERVED

The second half of the invariant is the join discipline. Because every fact carries auction_decision_id, the chain is never reconstructed by matching a click's timestamp to a conversion's — a method that silently mis-attributes whenever two users share a device or a household IP. The key is deterministic; the séance is banned. When the chain cannot be completed — an iOS opt-out left user_token null, so the conversion cannot be tied to an impression — the model does not guess. It records the conversion as unattributed and routes the measurement to the aggregate path, where SKAN-style postbacks live. Honesty about what cannot be joined is part of the invariant.


§ 05 — INGESTION & STREAMSPython on the chain

Three programs carry the write path: the impression joiner that completes the head of the chain, the click-invalidation consumer that corrects without overwriting, and the reservoir sampler that keeps the counterfactual affordable. Each is small; the judgment is in what they refuse to do.

1 · The impression joiner — the key, or nothing

An impression is only useful if it carries its auction_decision_id; a beacon that lost the key on the wire is unjoinable and therefore worthless to the chain. The joiner's whole discipline is to refuse keyless impressions to a quarantine rather than fabricate a join. It never invents a decision id, and it never timestamp-matches an orphan impression to a nearby auction — a guess here poisons every downstream metric.

PYTHON · IMPRESSION JOINER (KEY-OR-QUARANTINE)
async def ingest_impression(pool, ev: dict) -> None: """An impression without its auction_decision_id cannot join the chain and must never be faked. We refuse to timestamp-match an orphan to a nearby auction -- a fabricated join silently corrupts ROAS for the whole campaign. Keyless beacons go to quarantine for offline repair.""" did = ev.get("auction_decision_id") if not did: await quarantine("impression_no_key", ev) # never invent one return async with pool.acquire() as con: ok = await con.fetchval( "SELECT 1 FROM fct_auctions WHERE auction_decision_id = $1", did) if not ok: await quarantine("impression_orphan", ev) # auction not seen yet return # a late retry will re-land it await con.execute(""" INSERT INTO fct_impressions (auction_decision_id, creative_id, served_ts, predicted_pctr, quality_score, ad_rank) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING """, did, ev["creative_id"], ev["served_ts"], ev["predicted_pctr"], ev["quality_score"], ev["ad_rank"])

2 · The invalidation consumer — append the verdict

Bot-detection produces a stream of click ids it has judged invalid, sometimes days after the click billed. The consumer's one rule is that it corrects by appending a verdict, never by deleting the click — the row that charged the advertiser must remain to make billing reproducible. It is idempotent: a click already marked invalid is left exactly as it was, so a replayed detection batch cannot move the invalidation timestamp.

PYTHON · CLICK-INVALIDATION CONSUMER (CORRECT, DON'T DELETE)
async def apply_invalidations(pool, click_ids: list[int]) -> None: """Bot-detection lands hours-to-days late. We mark the click invalid by setting a flag + timestamp -- we do NOT delete it, because the row is the evidence for the bill we already sent. Idempotent: a click already invalid keeps its original invalidation_ts on replay.""" async with pool.acquire() as con: await con.executemany(""" UPDATE fct_clicks SET is_invalid = TRUE, invalidation_ts = now() WHERE click_id = $1 AND is_invalid = FALSE -- only the first verdict stamps the time """, [(cid,) for cid in click_ids]) # downstream billing reads `WHERE is_invalid = FALSE` for current credit, # but the historical bill is still reproducible from invalidation_ts.

3 · The loser sampler — keep 1%, reconstitute 100%

The losers are twenty times the winners and cannot be stored whole, but the ranker needs a balanced training set, so they are reservoir-sampled per auction and tagged with the inverse sampling rate. The sampler refuses to drop the population's shape: by stamping sample_weight = 1/rate on every retained row, downstream training reweights a 1% sample back to the full distribution without ever touching the discarded 99%.

PYTHON · RESERVOIR LOSER SAMPLER (REWEIGHTED)
import random SAMPLE_RATE = 0.01 # keep 1% of losing candidates WEIGHT = round(1 / SAMPLE_RATE) # 100: each kept row stands for 100 def sample_losers(decision_id: str, candidates: list[dict]) -> list[tuple]: """Per-auction reservoir sample of the losing candidates. The kept rows carry sample_weight = 1/rate so training reconstitutes the full 20x population -- we never need the discarded 99%. Sampling is what makes the counterfactual affordable AND statistically honest.""" losers = [c for c in candidates if not c["won"]] kept = [] for c in losers: if random.random() < SAMPLE_RATE: kept.append((decision_id, c["ad_id"], c["ad_rank"], WEIGHT)) return kept # -> fct_auction_losers; SUM(sample_weight) ~= true loser count

One carve-out, always stated: the sampler never touches winners. Every impression is retained in full, because the winner is the billed, served reality and the positive class of the training set — only the unbilled, unserved counterfactual is thinned. Keeping the asymmetry explicit is what lets the ranker team reweight correctly instead of training on an accidentally balanced sample that lies about how rare a win is.


§ 06 — AGGREGATIONThe attribution run and pCTR calibration

Two derived layers carry the slow loop. The attribution run materializes credit as an append-only batch tagged with a run id. Calibration grades the ranker by comparing the pCTR it predicted against the click-through it actually got — the feedback that keeps the auction's prices honest.

The attribution run is the slow loop's heart, and its defining property is that it is re-runnable. A run takes a model id, a window definition, and a slice of conversions; it pairs each conversion with its eligible in-window touchpoints, asks the model to distribute credit, and writes the result as new rows under a fresh attribution_run_id. Last-click is the trivial case — full credit to the last valid click in window. Data-driven splits credit across the path using the model's learned weights. The two run side by side over the identical events, and the latest run wins on read while every prior run remains queryable for audit. Re-attributing an entire quarter when MODEL_DDA_v2 becomes v3 is a batch job, not a re-ingestion — the raw facts never move.

PYTHON · ATTRIBUTION RUN (RE-RUNNABLE · APPEND-ONLY)
# One run = one (model, window, conversion-slice). Emits new rows under # a fresh run_id. Old runs are preserved; the latest_run view wins on read. def run_attribution(model, window, conversions, touchpoints): run_id = f"RUN_{date.today():%Y_%m_%d}_{model.id}" rows = [] for cv in conversions: eligible = touchpoints.in_window(cv, window) # clicks+views, in-window if not eligible: rows.append(unattributed(cv, run_id)) # credit 0 -- a real answer continue credits = model.distribute(eligible) # last_click -> {last:1.0} # DDA -> learned split assert abs(sum(credits.values()) - 1.0) < 1e-3 # § 04 invariant, in code for tp, credit in credits.items(): rows.append(attribution_row( cv, tp, model.id, run_id, window.name, credit, round(cv.value_usd * credit, 2))) return run_id, rows # append; never UPDATE a prior run

Calibration is the other half — the loop that grades the ranker. The auction's prices are only as fair as its pCTR predictions: a creative whose realized click-through drifts far from its predicted pCTR is either over- or under-priced, and the gap is a retraining signal. The job buckets impressions by creative, computes the realized CTR from the click join, and flags any creative whose calibration error exceeds a threshold. It runs nightly because pCTR drift is slow and statistical — a single hour's clicks are too noisy to act on.

The ranker's prediction is grade-able the moment the click lands. A creative that mis-predicts its own click-through is mis-priced in every auction it enters.RANKER RULE Nº 1 — CALIBRATE OR RE-TRAIN

§ 07 — ANALYTICS SQLInterrogating the auction

The facts are where the system explains itself — once you have the key. Three queries an interviewer loves, because each serves a different team and each carries a classic pattern on its back.

CTR, CPA, ROAS per campaign — the four-fact join on one key

The advertiser's defining query, and the whole reason for auction_decision_id. Four facts collapse into one campaign narrative by joining on a single column, with the latest attribution run pinned and invalid clicks excluded. The pattern is the conformed-key fact join — the chain reconstructed deterministically, not guessed.

SQL · CTR + CPA + ROAS (LATEST RUN, VALID CLICKS)
-- The advertiser narrative: four facts, one join key, latest run. ROAS -- is attributed revenue over spend -- the number every advertiser lives by. WITH latest_run AS ( SELECT max(attribution_run_id) AS run_id FROM fct_attributions ), campaign_metrics AS ( SELECT c.campaign_id, count(DISTINCT i.impression_id) AS impressions, count(DISTINCT cl.click_id) FILTER (WHERE cl.is_invalid = FALSE) AS clicks, sum(a.clearing_price_usd) AS spend_usd, count(DISTINCT atr.conversion_id) AS conversions, sum(atr.attributed_value_usd) AS attributed_revenue FROM fct_auctions a JOIN fct_impressions i USING (auction_decision_id) JOIN dim_creatives cr ON i.creative_id = cr.creative_id JOIN dim_campaigns c ON cr.campaign_id = c.campaign_id LEFT JOIN fct_clicks cl USING (impression_id) LEFT JOIN fct_attributions atr ON atr.click_id = cl.click_id AND atr.attribution_run_id = (SELECT run_id FROM latest_run) AND atr.model_id = 'MODEL_LAST_CLICK' WHERE a.auction_ts >= CURRENT_DATE - INTERVAL '7 days' GROUP BY c.campaign_id ) SELECT campaign_id, impressions, clicks, spend_usd, conversions, attributed_revenue, round(100.0 * clicks / nullif(impressions, 0), 3) AS ctr_pct, round(spend_usd / nullif(clicks, 0), 2) AS cpc_usd, round(spend_usd / nullif(conversions, 0), 2) AS cpa_usd, round(attributed_revenue / nullif(spend_usd, 0), 2) AS roas FROM campaign_metrics ORDER BY roas DESC;

Last-click vs data-driven — the model as a column, not an ETL fork

The measurement team's defining query. Because every model's output lives in the same table under the same run, comparing them is a GROUP BY model_id — last-click's full-credit world beside data-driven's distributed one. The pattern is dimensional comparison over a derived fact: the same conversions, two lenses, one scan.

SQL · ATTRIBUTED VALUE BY MODEL (SAME RUN)
-- Last-click vs DDA, side by side, from one run. Last-click hands full -- credit (avg ~1.0); DDA distributes -- the gap diagnoses over-attribution. SELECT atr.model_id, count(DISTINCT atr.conversion_id) AS conversions, round(sum(atr.attributed_value_usd), 2) AS total_attributed_value, round(avg(atr.fractional_credit), 3) AS avg_credit FROM fct_attributions atr WHERE atr.attribution_run_id = (SELECT max(attribution_run_id) FROM fct_attributions) GROUP BY atr.model_id ORDER BY total_attributed_value DESC;

Predicted vs realized pCTR — calibration as a windowed rate

The ranker team's defining query. Per creative, compare the average predicted pCTR against the realized click-through, and surface the creatives whose calibration error is large enough to demand retraining. The pattern is a FILTERed conditional rate against a predicted baseline — model accuracy, measured directly off the impression/click join.

SQL · pCTR CALIBRATION PER CREATIVE
-- The ranker's report card. Realized CTR vs the pCTR it predicted, per -- creative. Calibration error > 20% means the ranker needs attention. SELECT i.creative_id, count(*) AS impressions, round(avg(i.predicted_pctr), 4) AS predicted_pctr, round(count(*) FILTER (WHERE c.click_id IS NOT NULL)::decimal / count(*), 4) AS realized_pctr, round(100.0 * (count(*) FILTER (WHERE c.click_id IS NOT NULL)::decimal / count(*) - avg(i.predicted_pctr)) / nullif(avg(i.predicted_pctr), 0), 1) AS calibration_error_pct FROM fct_impressions i LEFT JOIN fct_clicks c USING (impression_id) WHERE i.served_ts >= CURRENT_DATE - INTERVAL '7 days' GROUP BY i.creative_id HAVING count(*) > 1000 ORDER BY abs(calibration_error_pct) DESC;

§ 08 — THE DASHBOARDProving the chain holds

A senior design ends with observability, because every clever re-run and carve-out above is invisible without it. The dashboard watches the chain from three seats — the auction, the funnel, and attribution — and one tile watches the thing that breaks silently: the join.

AUCTION HEALTH
win rate per advertiser, average ad_rank, average clearing price (CPC) — a sudden CPC jump with flat win rate means the second-price floor moved, not that demand did.
THE FUNNEL
CTR, CPA, ROAS on the latest run, plus pCTR calibration error — calibration drifting wide is an early warning that prices are going stale before ROAS even moves.
ATTRIBUTION
% conversions attributed to a click, last-click vs DDA delta, invalid-click rate — a widening last-click/DDA gap means last-click is over-crediting the final touch.
THE JOIN
chain match rate — the share of conversions that successfully threaded back to an impression. This is the silent killer: when it falls, every ROAS above it is quietly understated.
Search Ads Ops — Global THU 14:35 UTC · LATEST RUN 2025-05-02 · 5m REFRESH
Avg Clearing Price
1.92$
CTR (7d)
3.1%
Chain Match Rate
81%
Credit Sum Errors
0
ROAS — last-click vs DDA · Nike "running shoe" campaign
LAST-CLICK 67.7× DATA-DRIVEN v3 42.0×
% Conv. Attributed
79%
Invalid-Click Rate
2.7%
Worst Calibration Err
+34%
Loser Sample Rate
1.0%
Conv. Lag p50
11h
Re-Attribution Lag
38min
FIG. 2 — The story a healthy iOS shift tells: chain match rate drifting amber as opt-outs grow, ROAS reading 67.7× under last-click but 42.0× under data-driven — and credit-sum errors flat zero, because every run still reconciles to one.

Read the amber tiles together and the dashboard narrates a privacy-driven shift from the operator's chair: identity is harder to join (match rate falling), so a growing share of conversions route to the aggregate path, and last-click flatters the final touch while data-driven tells the truer story at 42.0× — yet nothing leaked, because the invariant held. The single most important tile is the boring one: credit-sum errors at zero. Match rate is a coverage problem you manage; credit conservation is a law you do not break.


§ 09 — THE RUBRICWhat was actually being tested

Strip the ads away and the question was testing five judgments, each of which generalizes far beyond search advertising:

THE KEY
Threading a causal chain with one deterministic id minted at the source, so a four-fact join is an equi-join — never a timestamp séance that mis-attributes the moment two users share a device.
COMPUTE, DON'T COLLECT
Recognizing that attribution is a function over events, and modeling it as a derived, append-only, run-tagged layer — so re-deciding the past is a query that preserves yesterday's answer.
LATENESS
Treating no fact as final on arrival: conversions land days late, invalid clicks later still, and the design restates by appending verdicts rather than overwriting billed rows.
ASYMMETRY
Sampling the 20× counterfactual to 1% and reweighting, instead of paying to store losers whole — and never thinning the winners, so the ranker learns how rare a win truly is.
HONEST PRIVACY
Making the privacy path first-class, so unjoinable conversions are recorded as unattributed and routed to the aggregate world — the model refuses to guess what consent withheld.
One key threads the chain. One run id lets you re-decide it. One sum-to-one keeps the credit honest. Everything else is the price of asking a question days after the auction already moved on.— CLOSING ARGUMENT