Ship a marketplace ranking candidate with eligible retrieval, separate recall and NDCG gates, replayable exposure rows, and an A/B-ready rollback receipt.
The ETA project produced one guarded prediction for one parcel. This project makes a decision that shapes its own future data: which products shoppers see near the top of search results.
Your task is to ship a ranking candidate for searches such as insulated delivery bag. It may reorder eligible, in-stock listings. It may not surface blocked listings or call clicks an unbiased truth signal.
Define the scope tightly:
| 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 | candidate recall@100 and NDCG@10 |
| online primary metric | purchase conversion per search |
| guardrails | returns, latency, unsafe listings, seller concentration |
Candidate generation and reranking are separate artifacts. The candidate component must retrieve relevant items reliably. The ranker can only adjust order inside that eligible set.
Learning-to-rank methods can learn pairwise preferences so a relevant product receives a higher score than a less relevant candidate.[1] That modeling detail doesn't excuse missing policy: eligibility filtering must run before scoring, and its version belongs in every impression log.
Create a judged fixture set. 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.pyRequired offline checks:
| Check | Why it blocks release |
|---|---|
| eligible-only results | a relevant prohibited listing still can't display |
| candidate recall@100 | the ranker can't repair missing products |
| NDCG@10 by query category | 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 |
Start with the boundary that a model can't learn safely: listing eligibility. A prohibited or sold-out product may have an excellent text match and a high learned score. It still can't reach the ranker.
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
5import json
6
7@dataclass(frozen=True)
8class Listing:
9 product_id: str
10 query: str
11 relevance: int
12 retrieval_score: float
13 baseline_rank_score: float
14 candidate_rank_score: float
15 in_stock: bool
16 policy_approved: bool
17 deliverable_region: bool
18 seller_id: str
19
20CATALOG = [
21 Listing("P1", "insulated-bag", 3, 0.96, 0.82, 0.97, True, True, True, "S1"),
22 Listing("P2", "insulated-bag", 1, 0.88, 0.75, 0.62, True, True, True, "S2"),
23 Listing("P3", "insulated-bag", 2, 0.84, 0.65, 0.90, True, True, True, "S3"),
24 Listing("P9", "insulated-bag", 3, 0.99, 0.99, 0.99, True, False, True, "S9"),
25 Listing("P10", "insulated-bag", 2, 0.95, 0.98, 0.96, False, True, True, "S1"),
26 Listing("P4", "label-printer", 1, 0.74, 0.90, 0.45, True, True, True, "S4"),
27 Listing("P5", "label-printer", 3, 0.93, 0.72, 0.96, True, True, True, "S5"),
28 Listing("P6", "label-printer", 2, 0.86, 0.65, 0.84, True, True, True, "S6"),
29 Listing("P8", "label-printer", 3, 0.98, 0.99, 0.99, False, False, True, "S8"),
30 Listing("P11", "label-printer", 2, 0.97, 0.97, 0.97, True, True, False, "S11"),
31]
32
33QUERIES = ("insulated-bag", "label-printer")
34
35def exclusion_reason(listing: Listing) -> str | None:
36 if not listing.policy_approved:
37 return "policy_block"
38 if not listing.in_stock:
39 return "out_of_stock"
40 if not listing.deliverable_region:
41 return "unavailable_region"
42 return None
43
44print("catalog listings:", len(CATALOG))
45print("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. Product P10 is approved but out of stock. Product P11 can't ship to the shopper's region. Filtering first prevents the ranker from treating a business invariant as a preference it can trade away.
Candidate recall asks whether relevant eligible products reached the ranker. Normalized discounted cumulative gain (NDCG) then asks whether stronger judgments appear near the top of the returned list. A ranker can't repair a relevant product that retrieval dropped.
The next cell reranks each eligible candidate set and compares baseline and candidate NDCG. It also rechecks the invariant after scoring.
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 for both query fixtures. The metric helpers also make two edge policies explicit: a query with no relevant eligible judgments is a fixture error for this capstone, while NDCG returns 0.0 when a ranked set has no positive gain. This still isn't a launch decision. Offline judgments support a controlled experiment, and the displayed slate creates the future data used to evaluate or retrain the system.
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 |
Clicks alone aren't reliable targets: top-ranked items receive attention because they are top-ranked. Joachims, Swaminathan, and Schnabel show why position bias makes direct click training suboptimal and derive a counterfactual correction for biased implicit feedback.[2] Use judged offline sets for regression detection, then use a controlled A/B experiment to compare user outcomes. Kohavi et al. describe online experimentation as a disciplined way to measure product changes.[3]
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. An A/B plan should state allocation, duration or sample-size rule, primary metric, latency and return-rate guardrails, prohibited-listing invariant, and stop conditions. A conversion lift that increases returns or policy violations isn't a successful ranking release.
The final cell publishes replayable impression rows, illustrative joined outcome events, and one candidate receipt. It doesn't invent live lift. It proves offline evidence, exposure schema, stable assignment, outcome join, and rollback pointer are ready before traffic begins.
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"
7
8ARM_CONFIG = {
9 "control": (PREVIOUS_RANKER, baseline_ranked),
10 "treatment": (RANKER_VERSION, candidate_ranked),
11}
12
13def impression_rows(
14 request_id: str,
15 shopper_id: str,
16 served_at: str,
17 query: str,
18 arm: str,
19) -> list[dict[str, object]]:
20 ranker_version, ranked_by_query = ARM_CONFIG[arm]
21 return [
22 {
23 "request_id": request_id,
24 "shopper_id": shopper_id,
25 "served_at": served_at,
26 "query": query,
27 "catalog_snapshot": CATALOG_SNAPSHOT,
28 "eligibility_version": ELIGIBILITY_VERSION,
29 "candidate_version": CANDIDATE_VERSION,
30 "ranker_version": ranker_version,
31 "experiment_id": EXPERIMENT_ID,
32 "experiment_arm": arm,
33 "product_id": listing.product_id,
34 "position": position,
35 }
36 for position, listing in enumerate(ranked_by_query[query], start=1)
37 ]
38
39print("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}
6required_outcome_fields = {"event_id", "occurred_at", "request_id", "product_id", "outcome"}
7outcome_event_ids = [row["event_id"] for row in outcomes]
8latencies_ms = [11, 13, 12, 15, 14, 16, 13, 12, 17, 18]
9
10def nearest_rank(values: list[int], percentile: float) -> int:
11 if not values:
12 raise ValueError("latency sample must not be empty")
13 rank = max(1, ceil(len(values) * percentile))
14 return sorted(values)[rank - 1]
15
16baseline_ndcg = sum(ndcg(rows) for rows in baseline_ranked.values()) / len(QUERIES)
17candidate_ndcg = sum(ndcg(rows) for rows in candidate_ranked.values()) / len(QUERIES)
18max_seller_count = max(
19 max(Counter(listing.seller_id for listing in rows).values())
20 for rows in candidate_ranked.values()
21)
22impression_index = {
23 (row["request_id"], row["product_id"]): row
24 for row in impressions
25}
26shopper_arms: dict[str, set[str]] = {}
27for row in impressions:
28 shopper_arms.setdefault(row["shopper_id"], set()).add(row["experiment_arm"])
29
30def parse_utc(value: str) -> datetime:
31 return datetime.fromisoformat(value.replace("Z", "+00:00"))
32
33def outcome_follows_impression(row: dict[str, object]) -> bool:
34 impression = impression_index.get((row["request_id"], row["product_id"]))
35 return impression is not None and parse_utc(impression["served_at"]) < parse_utc(row["occurred_at"])
36
37print("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 "outcome_schema": all(required_outcome_fields <= row.keys() for row in outcomes),
9 "outcome_event_ids_unique": len(outcome_event_ids) == len(set(outcome_event_ids)),
10 "outcomes_join_impressions": all((row["request_id"], row["product_id"]) in impression_index for row in outcomes),
11 "outcomes_follow_impressions": all(outcome_follows_impression(row) for row in outcomes),
12 "one_arm_per_shopper": all(len(arms) == 1 for arms in shopper_arms.values()),
13 "seller_concentration_at_most_2_per_slate": max_seller_count <= 2,
14}
15
16print("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 "candidate_decision": "candidate_for_ab_test" if all(release_gates.values()) else "hold",
29}
30
31print("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("first treatment impression:", json.dumps(treatment_impressions[0], sort_keys=True))
4print("first treatment outcome:", json.dumps(outcomes[0], sort_keys=True))
5print(json.dumps(receipt, indent=2))1control slate: ['P4', 'P5', 'P6']
2treatment slate: ['P5', 'P6', 'P4']
3first treatment impression: {"candidate_version": "retrieval-v1", "catalog_snapshot": "catalog-2026-05-01", "eligibility_version": "market-eligibility-v1", "experiment_arm": "treatment", "experiment_id": "ranking-ab-2026-05", "position": 1, "product_id": "P5", "query": "label-printer", "ranker_version": "market-ranker-v1", "request_id": "REQ-501", "served_at": "2026-05-01T12:00:05Z", "shopper_id": "SHOP-200"}
4first treatment outcome: {"event_id": "EVT-900", "occurred_at": "2026-05-01T12:00:08Z", "outcome": "click", "product_id": "P5", "request_id": "REQ-501"}
5{
6 "bundle_id": "market-ranker-v1",
7 "previous_ranker": "market-ranker-v0",
8 "catalog_snapshot": "catalog-2026-05-01",
9 "eligibility_version": "market-eligibility-v1",
10 "candidate_version": "retrieval-v1",
11 "offline": {
12 "baseline_ndcg_at_3": 0.854,
13 "candidate_ndcg_at_3": 1.0,
14 "ranker_p95_latency_ms": 18
15 },
16 "release_gates": {
17 "candidate_recall_complete": true,
18 "candidate_ndcg_improves": true,
19 "ndcg_slices_at_least_0_95": true,
20 "ranker_p95_latency_ms_at_most_20": true,
21 "blocked_items_absent": true,
22 "impression_schema": true,
23 "outcome_schema": true,
24 "outcome_event_ids_unique": true,
25 "outcomes_join_impressions": true,
26 "outcomes_follow_impressions": true,
27 "one_arm_per_shopper": true,
28 "seller_concentration_at_most_2_per_slate": true
29 },
30 "experiment": {
31 "experiment_id": "ranking-ab-2026-05",
32 "assignment_unit": "shopper_id",
33 "control": "market-ranker-v0",
34 "treatment": "market-ranker-v1",
35 "allocation": {
36 "control": 0.5,
37 "treatment": 0.5
38 },
39 "minimum_eligible_searches": 10000,
40 "primary_metric": "purchase_conversion_per_search",
41 "guardrails": [
42 "returns_per_purchase",
43 "search_p95_latency_ms",
44 "blocked_listing_impressions",
45 "seller_concentration_at_3"
46 ],
47 "stop_conditions": [
48 "blocked_listing_impressions > 0",
49 "search_p95_latency_ms > 120",
50 "returns_per_purchase > control + 0.01"
51 ]
52 },
53 "candidate_decision": "candidate_for_ab_test"
54}candidate_for_ab_test is intentionally 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 receipt says which ranker deserves controlled exposure, which immutable logs make later outcomes interpretable, and which previous alias remains available if guardrails fail.
| 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 |
Use the runnable examples as a release harness. Change one condition at a time, predict the result, then rerun the examples.
P3 retrieval score from 0.84 to 0.10, then change retrieve() default budget from 3 to 2. Which metric exposes missing relevant product?P9.policy_approved to True. Why isn't that a harmless relevance change?P6 from 0.84 to 0.20. Which NDCG slice fails?position from impression_rows(). Which receipt gate blocks experiment readiness?insulated-bag listings to seller S1. Which marketplace guardrail fails?SHOP-100 for both control and treatment impressions. Which assignment gate fails?product_id from P5 to P9. Which replay gate fails?event_id as EVT-900. Which deduplication gate fails?| Artifact | Strong submission demonstrates |
|---|---|
| ranking stack | explicit eligibility, candidate generation, reranking inputs, and replayable slate logs |
| evaluation | NDCG-style offline result with policy guardrails and slice review |
| experiment plan | immutable impressions, joined outcomes, stable assignment, guardrails, stopping rule, and rollback |
| Symptom | Cause | Fix |
|---|---|---|
| Great NDCG while prohibited item appears | ranking runs before eligibility | filter first and test invariant |
| Strong ranker metric while relevant product never appears | candidate retrieval dropped it before reranking | gate candidate recall separately |
| New model increasingly favors former winners | clicks used without exposure evidence | log impressions and experiment arms |
| Outcome history changes after retries | mutable impression row overwritten with later status | append outcome events and join by request plus product |
| Same shopper appears in both A/B arms | assignment identity wasn't preserved | log shopper and experiment IDs before exposure |
| Conversion increases while customer value falls | returns absent from guardrails | gate promotion on downstream outcomes |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Learning to Rank using Gradient Descent.
Burges, C. J. C., et al. · 2005 · ICML 2005
Unbiased Learning-to-Rank with Biased Feedback
Joachims, T., Swaminathan, A., & Schnabel, T. · 2017 · WSDM 2017
Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.
Kohavi, R., Tang, D., Xu, Y. · 2020
Questions and insights from fellow learners.