Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A shopper types label-printer. The ranker decides which products sit near the top, and those positions then collect the clicks you'll be tempted to treat as labels.
Capstone: Delivery ETA Prediction shipped a delay warning from time-safe features and a receipt that only qualified shadow traffic. A search ranker is a different kind of service. Ranking and Recommendation Systems already separated eligibility, candidate recall, and NDCG on a docs corpus. Package that funnel into a marketplace artifact. Stock, region, and policy have to win before any learned score. Impressions have to freeze before outcomes arrive. An offline NDCG win still isn't a launch. Ship a candidate that may reorder eligible, in-stock listings, and that may not surface blocked listings or call clicks an unbiased truth signal.

label-printer, the two strongest raw matches never enter ranking: P8 is policy-blocked (and also out of stock) and P11 can't ship to the shopper's region. The ranker only reorders the three survivors. Control puts weak P4 first (NDCG@3 0.736); treatment restores the judged order (NDCG@3 1.000).Specify the ranking surface
Pin the product before you pick a model:
| Contract | Decision |
|---|---|
| surface | marketplace search results |
| query set | frozen judged search queries plus online experiment traffic |
| eligibility | in-stock, deliverable region, policy-approved listing |
| offline metric | recall@K and NDCG@K on the fixture (this lesson uses ; production targets are often recall@100 and NDCG@10) |
| online primary metric | purchase conversion per search |
| guardrails | returns, latency, unsafe listings, seller concentration |
Candidate generation and reranking stay separate artifacts. The candidate component has to recover relevant eligible items. The ranker can only change order inside that set.
The earlier ranking lesson used pairwise preferences the way RankNet does: a relevant product should outscore a weaker candidate.[1] That modeling choice still doesn't let a learned score override eligibility. Filter first, then score, and pin the eligibility version on every impression log.

Build offline evidence first
A live shopper log can't grade that contract. Start with a judged fixture: each query has eligible candidates and graded relevance from human review.
1ranking-product/
2 data/
3 catalog_snapshot.jsonl
4 judged_queries.jsonl
5 eligibility_policy.json
6 retrieval/
7 candidate_generator.py
8 ranking/
9 ranker.py
10 evaluate_ndcg.py
11 experiments/
12 impression_schema.json
13 ab_plan.md
14 tests/
15 test_blocked_listing_never_surfaces.py
16 test_ndcg_regression_gate.pyOffline gates in this lesson score NDCG@3 / recall@3 on the small judged fixture. Production contracts often pin NDCG@10 and recall@100; keep the K explicit so the receipt and the surface table match.
Required offline checks:
| Check | Why it blocks release |
|---|---|
| eligible-only results | a relevant prohibited listing still can't display |
| candidate recall@K (fixture @3; prod often @100) | the ranker can't repair missing products |
| NDCG@K by query category (fixture @3; prod often @10) | overall improvement may hide poor critical categories |
| scoring latency | a slower list damages shopping experience |
| diversity/seller concentration | one seller with many feedback events shouldn't crowd out catalog |
Those gates only mean something if blocked listings never enter scoring. Start there.
Filter policy before retrieval
A prohibited or sold-out product can still have an excellent text match and a high learned score. It still can't reach the ranker. Eligibility is the boundary a model can't learn safely from clicks.
The compact fixture below keeps two judged searches in memory. Production might retrieve 100 candidates per query; this local receipt uses 3 so each listing stays visible.
1from collections import Counter
2from dataclasses import dataclass
3from datetime import datetime
4from math import ceil, log2
5
6@dataclass(frozen=True)
7class Listing:
8 product_id: str
9 query: str
10 relevance: int
11 retrieval_score: float
12 baseline_rank_score: float
13 candidate_rank_score: float
14 in_stock: bool
15 policy_approved: bool
16 deliverable_region: bool
17 seller_id: str
18
19CATALOG = [
20 Listing("P1", "insulated-bag", 3, 0.96, 0.82, 0.97, True, True, True, "S1"),
21 Listing("P2", "insulated-bag", 1, 0.88, 0.75, 0.62, True, True, True, "S2"),
22 Listing("P3", "insulated-bag", 2, 0.84, 0.65, 0.90, True, True, True, "S3"),
23 Listing("P9", "insulated-bag", 3, 0.99, 0.99, 0.99, True, False, True, "S9"),
24 Listing("P10", "insulated-bag", 2, 0.95, 0.98, 0.96, False, True, True, "S1"),
25 Listing("P4", "label-printer", 1, 0.74, 0.90, 0.45, True, True, True, "S4"),
26 Listing("P5", "label-printer", 3, 0.93, 0.72, 0.96, True, True, True, "S5"),
27 Listing("P6", "label-printer", 2, 0.86, 0.65, 0.84, True, True, True, "S6"),
28 Listing("P8", "label-printer", 3, 0.98, 0.99, 0.99, False, False, True, "S8"),
29 Listing("P11", "label-printer", 2, 0.97, 0.97, 0.97, True, True, False, "S11"),
30]
31
32QUERIES = ("insulated-bag", "label-printer")
33
34def exclusion_reason(listing: Listing) -> str | None:
35 if not listing.policy_approved:
36 return "policy_block"
37 if not listing.in_stock:
38 return "out_of_stock"
39 if not listing.deliverable_region:
40 return "unavailable_region"
41 return None
42
43print("catalog listings:", len(CATALOG))
44print("queries:", QUERIES)1catalog listings: 10
2queries: ('insulated-bag', 'label-printer')1def retrieve(query: str, budget: int = 3) -> list[Listing]:
2 eligible = [
3 listing for listing in CATALOG
4 if listing.query == query and exclusion_reason(listing) is None
5 ]
6 return sorted(eligible, key=lambda listing: (-listing.retrieval_score, listing.product_id))[:budget]
7
8def relevant_ids(query: str) -> set[str]:
9 return {
10 listing.product_id for listing in CATALOG
11 if listing.query == query and exclusion_reason(listing) is None and listing.relevance >= 2
12 }
13
14def candidate_recall(query: str, candidates: list[Listing]) -> float:
15 relevant = relevant_ids(query)
16 if not relevant:
17 raise ValueError(f"{query}: no relevant eligible judgments")
18 return len(relevant & {listing.product_id for listing in candidates}) / len(relevant)
19
20retrieved = {query: retrieve(query) for query in QUERIES}
21rejected = {
22 listing.product_id: exclusion_reason(listing)
23 for listing in CATALOG
24 if exclusion_reason(listing) is not None
25}
26
27for query, candidates in retrieved.items():
28 print(query, [listing.product_id for listing in candidates], f"recall@3={candidate_recall(query, candidates):.3f}")
29print("rejected:", rejected)1insulated-bag ['P1', 'P2', 'P3'] recall@3=1.000
2label-printer ['P5', 'P6', 'P4'] recall@3=1.000
3rejected: {'P9': 'policy_block', 'P10': 'out_of_stock', 'P8': 'policy_block', 'P11': 'unavailable_region'}Products P9 and P8 have the strongest scores for their searches, but policy excludes them. P8 is also out of stock; exclusion_reason() logs the first failing check, so the receipt shows policy_block. Product P10 is approved but sold out. Product P11 can't ship to the shopper's region. Filtering first stops the ranker from treating a business invariant as a preference it can trade away.
Measure retrieval and ordering separately
Candidate recall asks whether relevant eligible products reached the ranker. NDCG then asks whether stronger judgments sit near the top of the returned list. A ranker can't repair a relevant product that retrieval dropped.
Use the same exponential-gain form as the ranking chapter. At cutoff , discounted cumulative gain is
where is the judged grade at 1-based position . Position 1 has , so it keeps full weight. NDCG divides by the DCG of the ideal ordering of that same candidate set. Manning, Raghavan, and Schütze document this exponential-gain form; some libraries use linear gain instead, so the eval setup has to pin one.[2] In the helper below, enumerate is 0-based, so the denominator is log2(rank + 2), which equals .
Work label-printer by hand before looking at code. Control shows P4, P5, P6 with grades , , and :
The ideal order is P5, P6, P4 with grades , , and :
So control NDCG@3 is . Treatment is already the ideal order, so its NDCG@3 is . Recall@3 is still on both arms because P5 and P6 (the eligible grades ) are in the candidate set either way. Ordering quality moved; retrieval didn't.
The next cells rerank each eligible set, print those NDCG values, and recheck that scoring didn't sneak a blocked listing back in.
1def dcg(rows: list[Listing]) -> float:
2 return sum((2**listing.relevance - 1) / log2(rank + 2) for rank, listing in enumerate(rows))
3
4def ndcg(rows: list[Listing]) -> float:
5 ideal = sorted(rows, key=lambda listing: (-listing.relevance, listing.product_id))
6 ideal_dcg = dcg(ideal)
7 return dcg(rows) / ideal_dcg if ideal_dcg else 0.0
8
9def rerank(rows: list[Listing], score_field: str) -> list[Listing]:
10 return sorted(rows, key=lambda listing: (-getattr(listing, score_field), listing.product_id))
11
12baseline_ranked = {
13 query: rerank(candidates, "baseline_rank_score")
14 for query, candidates in retrieved.items()
15}
16candidate_ranked = {
17 query: rerank(candidates, "candidate_rank_score")
18 for query, candidates in retrieved.items()
19}
20
21print("queries ranked:", list(candidate_ranked))1for query in QUERIES:
2 print(
3 query,
4 f"baseline_ndcg@3={ndcg(baseline_ranked[query]):.3f}",
5 f"candidate_ndcg@3={ndcg(candidate_ranked[query]):.3f}",
6 )
7
8blocked_hits = [
9 listing.product_id
10 for rows in candidate_ranked.values()
11 for listing in rows
12 if exclusion_reason(listing) is not None
13]
14print("blocked hits:", blocked_hits)1insulated-bag baseline_ndcg@3=0.972 candidate_ndcg@3=1.000
2label-printer baseline_ndcg@3=0.736 candidate_ndcg@3=1.000
3blocked hits: []The candidate improves ordering on both fixture queries. Two edge policies stay explicit: a query with no relevant eligible judgments is a fixture error here, and NDCG returns 0.0 when a ranked set has no positive gain. That still isn't a launch decision. Offline judgments only qualify a controlled experiment, and the displayed slate is the data you'll later be tempted to train on.
Log exposure before learning from outcomes
If that slate is going to become training data, freeze what was shown before any click arrives.
Every displayed slate should log:
| Field | Why |
|---|---|
request_id, query, served_at | group one displayed decision |
shopper_id, experiment_id, experiment_arm | replay stable experiment assignment |
catalog_snapshot, eligibility_version | show available choices |
candidate_version, ranker_version | identify scoring path |
product_id, position | preserve exposure |
position_propensity, propensity_model_version | enable inverse propensity scoring (IPS) on implicit feedback |
Clicks alone aren't reliable targets. Top-ranked items get attention because they're top-ranked. That's position bias. Joachims, Swaminathan, and Schnabel show that using raw clicks as learning-to-rank labels is biased, and they derive a propensity-weighted IPS correction for that implicit feedback.[3] Their method still needs a real propensity model, often from a small randomized logging study, not a made-up curve. A formula such as 1 / position isn't a measured propensity. When training_use=implicit, refuse to train unless the impression row carries the actual randomized action probability or a calibrated examination propensity with a pinned model version.
Use the judged fixture to catch regressions. Use a controlled A/B experiment to compare shopper outcomes. Experiment Design and A/B Testing already made the assignment unit, primary metric, and guardrails explicit; Kohavi, Tang, and Xu treat that online comparison as the way to measure a product change.[4] Keep each shopper in one arm. A conversion lift that raises returns or policy violations isn't a successful ranking release.
Clicks, purchases, and returns arrive later. Append them as outcome events with event_id, occurred_at, request_id, product_id, and outcome. Don't overwrite the served impression row.
The remaining cells publish replayable impression rows, two joined outcome events, and one candidate receipt. They don't invent live lift. They prove offline evidence, exposure schema, stable assignment, outcome join, and a rollback pointer are ready before traffic starts.
1CATALOG_SNAPSHOT = "catalog-2026-05-01"
2ELIGIBILITY_VERSION = "market-eligibility-v1"
3CANDIDATE_VERSION = "retrieval-v1"
4RANKER_VERSION = "market-ranker-v1"
5PREVIOUS_RANKER = "market-ranker-v0"
6EXPERIMENT_ID = "ranking-ab-2026-05"
7PROPENSITY_MODEL_VERSION = "position-examination-randomized-v1"
8# Frozen estimates from a separate randomized-position logging study.
9POSITION_PROPENSITY = {1: 0.95, 2: 0.78, 3: 0.62}
10
11ARM_CONFIG = {
12 "control": (PREVIOUS_RANKER, baseline_ranked),
13 "treatment": (RANKER_VERSION, candidate_ranked),
14}
15
16def impression_rows(
17 request_id: str,
18 shopper_id: str,
19 served_at: str,
20 query: str,
21 arm: str,
22) -> list[dict[str, object]]:
23 ranker_version, ranked_by_query = ARM_CONFIG[arm]
24 return [
25 {
26 "request_id": request_id,
27 "shopper_id": shopper_id,
28 "served_at": served_at,
29 "query": query,
30 "catalog_snapshot": CATALOG_SNAPSHOT,
31 "eligibility_version": ELIGIBILITY_VERSION,
32 "candidate_version": CANDIDATE_VERSION,
33 "ranker_version": ranker_version,
34 "experiment_id": EXPERIMENT_ID,
35 "experiment_arm": arm,
36 "product_id": listing.product_id,
37 "position": position,
38 "position_propensity": POSITION_PROPENSITY[position],
39 "propensity_model_version": PROPENSITY_MODEL_VERSION,
40 "slate_size": len(ranked_by_query[query]),
41 "training_use": "implicit",
42 }
43 for position, listing in enumerate(ranked_by_query[query], start=1)
44 ]
45
46print("experiment:", EXPERIMENT_ID, "arms:", list(ARM_CONFIG))1control_impressions = impression_rows("REQ-500", "SHOP-100", "2026-05-01T12:00:00Z", "label-printer", "control")
2treatment_impressions = impression_rows("REQ-501", "SHOP-200", "2026-05-01T12:00:05Z", "label-printer", "treatment")
3impressions = control_impressions + treatment_impressions
4outcomes = [
5 {
6 "event_id": "EVT-900",
7 "occurred_at": "2026-05-01T12:00:08Z",
8 "request_id": "REQ-501",
9 "product_id": "P5",
10 "outcome": "click",
11 },
12 {
13 "event_id": "EVT-901",
14 "occurred_at": "2026-05-01T12:04:18Z",
15 "request_id": "REQ-501",
16 "product_id": "P5",
17 "outcome": "purchase",
18 },
19]
20
21print("impressions:", len(impressions), "outcomes:", len(outcomes))1required_impression_fields = {
2 "request_id", "shopper_id", "served_at", "query", "catalog_snapshot",
3 "eligibility_version", "candidate_version", "ranker_version",
4 "experiment_id", "experiment_arm", "product_id", "position",
5 "position_propensity", "propensity_model_version", "slate_size", "training_use",
6}
7required_outcome_fields = {"event_id", "occurred_at", "request_id", "product_id", "outcome"}
8outcome_event_ids = [row["event_id"] for row in outcomes]
9latencies_ms = [11, 13, 12, 15, 14, 16, 13, 12, 17, 18]
10# Toy online conversion table on the two arms (stop-rule practice).
11arm_searches = {"control": 100, "treatment": 100}
12arm_purchases = {"control": 4, "treatment": 6}
13arm_returns = {"control": 0, "treatment": 1}
14conversion = {
15 arm: arm_purchases[arm] / arm_searches[arm]
16 for arm in arm_searches
17}
18return_rate = {
19 arm: (arm_returns[arm] / arm_purchases[arm]) if arm_purchases[arm] else 0.0
20 for arm in arm_searches
21}
22stop_returns = return_rate["treatment"] > return_rate["control"] + 0.01
23
24def nearest_rank(values: list[int], percentile: float) -> int:
25 if not values:
26 raise ValueError("latency sample must not be empty")
27 rank = max(1, ceil(len(values) * percentile))
28 return sorted(values)[rank - 1]
29
30baseline_ndcg = sum(ndcg(rows) for rows in baseline_ranked.values()) / len(QUERIES)
31candidate_ndcg = sum(ndcg(rows) for rows in candidate_ranked.values()) / len(QUERIES)
32max_seller_count = max(
33 max(Counter(listing.seller_id for listing in rows).values())
34 for rows in candidate_ranked.values()
35)
36impression_index = {
37 (row["request_id"], row["product_id"]): row
38 for row in impressions
39}
40shopper_arms: dict[str, set[str]] = {}
41for row in impressions:
42 shopper_arms.setdefault(row["shopper_id"], set()).add(row["experiment_arm"])
43
44def parse_utc(value: str) -> datetime:
45 return datetime.fromisoformat(value.replace("Z", "+00:00"))
46
47def outcome_follows_impression(row: dict[str, object]) -> bool:
48 impression = impression_index.get((row["request_id"], row["product_id"]))
49 return impression is not None and parse_utc(impression["served_at"]) < parse_utc(row["occurred_at"])
50
51print("baseline_ndcg:", round(baseline_ndcg, 3), "candidate_ndcg:", round(candidate_ndcg, 3))1release_gates = {
2 "candidate_recall_complete": all(candidate_recall(query, retrieved[query]) == 1.0 for query in QUERIES),
3 "candidate_ndcg_improves": candidate_ndcg > baseline_ndcg,
4 "ndcg_slices_at_least_0_95": all(ndcg(rows) >= 0.95 for rows in candidate_ranked.values()),
5 "ranker_p95_latency_ms_at_most_20": nearest_rank(latencies_ms, 0.95) <= 20,
6 "blocked_items_absent": not blocked_hits,
7 "impression_schema": all(required_impression_fields <= row.keys() for row in impressions),
8 "implicit_feedback_has_propensity": all(
9 row.get("training_use") != "implicit"
10 or (
11 isinstance(row.get("position_propensity"), (int, float))
12 and 0 < row["position_propensity"] <= 1
13 and row.get("propensity_model_version") == PROPENSITY_MODEL_VERSION
14 and isinstance(row.get("slate_size"), int)
15 and row["slate_size"] >= 1
16 )
17 for row in impressions
18 ),
19 "outcome_schema": all(required_outcome_fields <= row.keys() for row in outcomes),
20 "outcome_event_ids_unique": len(outcome_event_ids) == len(set(outcome_event_ids)),
21 "outcomes_join_impressions": all((row["request_id"], row["product_id"]) in impression_index for row in outcomes),
22 "outcomes_follow_impressions": all(outcome_follows_impression(row) for row in outcomes),
23 "one_arm_per_shopper": all(len(arms) == 1 for arms in shopper_arms.values()),
24 "seller_concentration_at_most_2_per_slate": max_seller_count <= 2,
25}
26
27print("release_gates_pass:", all(release_gates.values()))1receipt = {
2 "bundle_id": RANKER_VERSION,
3 "previous_ranker": PREVIOUS_RANKER,
4 "catalog_snapshot": CATALOG_SNAPSHOT,
5 "eligibility_version": ELIGIBILITY_VERSION,
6 "candidate_version": CANDIDATE_VERSION,
7 "offline": {
8 "baseline_ndcg_at_3": round(baseline_ndcg, 3),
9 "candidate_ndcg_at_3": round(candidate_ndcg, 3),
10 "ranker_p95_latency_ms": nearest_rank(latencies_ms, 0.95),
11 },
12 "release_gates": release_gates,
13 "experiment": {
14 "experiment_id": EXPERIMENT_ID,
15 "assignment_unit": "shopper_id",
16 "control": PREVIOUS_RANKER,
17 "treatment": RANKER_VERSION,
18 "allocation": {"control": 0.5, "treatment": 0.5},
19 "minimum_eligible_searches": 10000,
20 "primary_metric": "purchase_conversion_per_search",
21 "guardrails": ["returns_per_purchase", "search_p95_latency_ms", "blocked_listing_impressions", "seller_concentration_at_3"],
22 "stop_conditions": [
23 "blocked_listing_impressions > 0",
24 "search_p95_latency_ms > 120",
25 "returns_per_purchase > control + 0.01",
26 ],
27 },
28 "illustrative_online_review": {
29 "status": "stop_treatment_return_guardrail",
30 "control_conversion": conversion["control"],
31 "treatment_conversion": conversion["treatment"],
32 "control_return_rate": return_rate["control"],
33 "treatment_return_rate": return_rate["treatment"],
34 "stop_returns": stop_returns,
35 "release_evidence": False,
36 },
37 "candidate_decision": "candidate_for_ab_test" if all(release_gates.values()) else "hold",
38}
39
40print("candidate_decision:", receipt["candidate_decision"])1print("control slate:", [row["product_id"] for row in control_impressions])
2print("treatment slate:", [row["product_id"] for row in treatment_impressions])
3print("offline:", receipt["offline"])
4print("release_gates_pass:", all(receipt["release_gates"].values()))
5print("online_review:", receipt["illustrative_online_review"]["status"])
6print("candidate_decision:", receipt["candidate_decision"])1control slate: ['P4', 'P5', 'P6']
2treatment slate: ['P5', 'P6', 'P4']
3offline: {'baseline_ndcg_at_3': 0.854, 'candidate_ndcg_at_3': 1.0, 'ranker_p95_latency_ms': 18}
4release_gates_pass: True
5online_review: stop_treatment_return_guardrail
6candidate_decision: candidate_for_ab_testcandidate_for_ab_test is narrower than launch approval. The local 20 millisecond gate measures ranker scoring only; the experiment's 120 millisecond stop applies to end-to-end search latency. The tiny online table is stop-rule practice, not live evidence:
| Arm | Searches | Purchases | Conversion | Returns / purchase |
|---|---|---|---|---|
| control | 100 | 4 | 0.04 | 0.00 |
| treatment | 100 | 6 | 0.06 | 0.17 |
Treatment conversion rises, and the return-rate guardrail still fires (0.17 is far above control plus 0.01). The receipt says which ranker deserves controlled exposure, which immutable logs make later outcomes interpretable, and which previous alias stays available if a guardrail fails.
Submission checklist
| Artifact | Evidence |
|---|---|
| eligibility policy | blocked items can't enter ranking |
| candidate evaluation | recall measured before reranking |
| ranker evaluation | NDCG slices and latency recorded |
| impression schema | positions, versions, and assignment context logged before outcomes |
| outcome stream | later events append separately and join displayed products |
| experiment plan | stable assignment, metrics, guardrails, stop rules |
| rollback | stable ranker alias remains available |
Practice: break the ranking contract
Use the runnable examples as a verification testbed. Change one condition at a time, predict the result, then rerun the examples.
- Change
P3retrieval score from0.84to0.10, then changeretrieve()default budget from3to2. Which metric exposes missing relevant product? - Set
P9.policy_approvedtoTrue. Why isn't that a harmless relevance change? - Change candidate score for
P6from0.84to0.20. Doesndcg_slices_at_least_0_95fail? - Remove
positionfromimpression_rows(). Which receipt gate blocks experiment readiness? - Change all three eligible
insulated-baglistings to sellerS1. Which marketplace guardrail fails? - Use
SHOP-100for both control and treatment impressions. Which assignment gate fails? - Change first outcome's
product_idfromP5toP9. Which replay gate fails? - Append a retry with the same
event_idasEVT-900. Which deduplication gate fails?
Practice answer sketches
Which metric catches dropped P3 after retrieval budget falls to 2?
Answer
candidate_recall_complete fails because the ranker never receives one relevant eligible product. NDCG on surviving candidates can't repair that omission.
Why isn't approving P9 a harmless relevance edit?
Answer
P9 was excluded by marketplace policy. Changing that field changes eligibility contract, not ranking quality, and requires its own reviewed policy release.
What happens when P6 candidate score falls from 0.84 to 0.20?
Answer
label-printer is no longer ideal (P5, P4, P6 instead of P5, P6, P4), but NDCG@3 only falls to about 0.972, which still clears 0.95. A worse-than-ideal list isn't the same as a failed slice gate. Demoting relevance-3 P5 to the bottom is what drops the slice below 0.95.
Which gate fails when impression rows drop position?
Answer
impression_schema fails. Without displayed position, later click and purchase data can't be interpreted against exposure.
Which gate fails when three products in one displayed slate share seller S1?
Answer
seller_concentration_at_most_2_per_slate fails, blocking an experiment that would crowd too much exposure into one seller.
Which gate fails when SHOP-100 appears in both experiment arms?
Answer
one_arm_per_shopper fails. A shopper-level experiment must keep each shopper in one arm so treatment effects stay interpretable.
Which gate fails when outcome product P9 was never displayed for REQ-501?
Answer
outcomes_join_impressions fails. A click or purchase can't become ranking feedback unless it joins a product exposed by the same request.
Which gate fails when a retry reuses EVT-900?
Answer
outcome_event_ids_unique fails. Event IDs distinguish one outcome from a replayed delivery, so a duplicate must be deduplicated before training or experiment analysis.