Ad Server: Auction Fixes, Budget Pacing and Mid-Day Changes
MediumAI-assisted coding · AI-Assisted Debugging
Ad Server: Auction Fixes, Budget Pacing and Mid-Day Changes
You've joined the ads platform team. Their small ad server decides, for every ad request, which advertiser's campaign gets the slot and what it pays. Advertisers are complaining about overcharges and about frequency caps that are off by a second; finance wants budgets that last the whole day instead of burning out by lunchtime; and sales has just promised advertisers that "you only ever pay for what you have left". All money is integer cents.
Codebase
| File | What it does |
|---|---|
adserver/api.py | run(operations) entry point and the Dispatcher that routes each operation |
adserver/server.py | AdServer: creates campaigns, decides who may bid, serves requests, builds stats |
adserver/auction.py | run_auction and clearing_price: winner and price among the allowed bidders |
adserver/frequency.py | FrequencyLedger: which user saw which campaign, and when |
adserver/budget.py | BudgetBook: what each campaign has spent |
adserver/pacing.py | PacingPolicy: stub for Part 2 (today it lets everyone bid) |
adserver/targeting.py | Placement and active-hours matching |
adserver/catalog.py, models.py, validation.py | Campaign storage (in creation order), the Campaign record, field validators |
adserver/clock.py, config.py, errors.py | Day/hour arithmetic, constants (reserve, tick, window), error codes |
adserver/loadgen.py | Deterministic synthetic traffic generator used by Part 2 |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh ad server and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer number of seconds that never decreases. Time starts at ts = 0 = 00:00 of day 0; day = ts // 86400 and hour = (ts % 86400) // 3600 (0..23). Errors are returned as "error: <code>" strings. An unknown operation returns "error: unknown operation"; a wrong number of arguments or a non-integer ts returns "error: invalid arguments".
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
campaign | id, bid, daily_budget, placements, hours, freq_cap | true | invalid arguments (id not a non-empty string), campaign exists, invalid bid, invalid budget, invalid placements, invalid hours, invalid frequency cap |
request | user, placement | {"campaign", "price", "charged"} or null | invalid arguments (user or placement not a non-empty string) |
stats | id | {"impressions", "spent_today", "remaining"} | campaign not found |
bidis the most the campaign will pay per impression: an integer>= 50(the reserve).daily_budgetis an integer> 0.placementsis a non-empty list of non-empty strings.hoursis[start, end]with integers0 <= start < end <= 24; the campaign is active fromstart:00:00up to but not includingend:00:00.freq_capis an integer>= 1.stats:impressionscounts all impressions ever served;spent_todayandremaining(daily_budget - spent_today, never below 0) are for the day ofts.
How an ad request is decided
- Who may bid. A campaign bids only if *all* hold: the request's placement is one of its placements; the hour is inside its active hours; it has enough budget left (see below); the user has seen it fewer than
freq_captimes in the rolling 24 h window(ts - 86400, ts](an impression at exactlyts - 86400has expired); and (Part 2) pacing allows it. - Winner. The highest bid wins. Equal bids go to the campaign created first.
- Price. The winner pays one cent more than the runner-up's bid, but never more than its own bid, and never less than the reserve of 50. With a single bidder the price is the reserve. (
price = max(50, min(winner_bid, runner_up_bid + 1)).) - The winner is charged, the impression is recorded against the user, and
requestreturns{"campaign": id, "price": price, "charged": amount_billed};nullif nobody bids. For nowcharged == price. - Budgets are per calendar day: spend starts at 0 on every new day.
- Until Part 3: a campaign has enough budget only if
remaining >= bid(it must be able to cover its own maximum bid).
Worked example
[["campaign", 0, "c1", 200, 100000, ["home"], [0, 24], 1],
["campaign", 0, "c9", 150, 100000, ["home"], [0, 24], 5],
["campaign", 0, "c10", 150, 100000, ["home"], [0, 24], 5],
["request", 100, "u1", "home"],
["request", 200, "u1", "home"],
["request", 300, "u2", "feed"],
["request", 86499, "u1", "home"],
["request", 86500, "u1", "home"]]returns
[true, true, true,
{"campaign": "c1", "price": 151, "charged": 151},
{"campaign": "c9", "price": 150, "charged": 150},
null,
{"campaign": "c9", "price": 150, "charged": 150},
{"campaign": "c1", "price": 151, "charged": 151}]c1 wins first (price = runner-up 150 + 1). u1 has hit c1's cap of 1, so the tied c9/c10 pair competes: c9 was created first, and pays its own bid (150 + 1 would exceed it). Nobody serves feed. c1's impression at 100 is still inside the window at 86499 but expired at 86500.
Part 1 — Advertiser complaints about the auction (ADS-2190)
Goal: make the existing server follow the rules above. Complaints from support:
- "Two of us bid 120 and I was charged 121 for a 120 bid."
- "My frequency cap lets a user see the ad again one second too late" / "...one second too early."
- "Our campaign stopped serving on day 2 and never came back."
- "Tied bids don't go to whoever signed up first (we are
c9, they arec10)."
Fix whatever is wrong; the rules in this README are the spec. The failing tests in tests/ point at some of it, not all.
Part 2 — Budget pacing and a load replay (ADS-2214)
Goal: stop campaigns burning their whole daily budget in the first hour. Add pacing to the bidding rules, an operation to inspect it, and a load-replay operation to check the system at volume.
Pacing rule. At time ts, with h = hour of ts:
spent_before_hour= cents the campaign had spent today when the current hour began (spent_todayminus what it spent in this hour).quota = max(0, daily_budget - spent_before_hour) // (24 - h)(integer division). The 24 - h counts every remaining hour slot of the day, the current one included, whether or not the campaign is active in those hours. At 23:xx the quota is everything that is left.- A campaign may bid only while
spent_this_hour < quota. Once its spend this hour reaches the quota it is throttled until the next hour (where the quota is recomputed from what is left). A throttled campaign does not bid: it cannot win and cannot set anyone's price. This is an extra eligibility condition added to rule 1 above; the quota is only a gate, so the winning impression that crosses it is billed in full. - "This hour" is the clock hour (
ts // 3600); a new day also starts a new hour.
| Operation | Arguments after ts | Returns | Errors |
|---|---|---|---|
pacing | id | {"hour", "quota", "hour_spent", "throttled"} for the hour of ts | campaign not found |
traffic | count, step, users, seed | summary (below) | invalid arguments |
traffic replays count generated ad requests as if they were ordinary request operations, at times ts, ts + step, ts + 2*step, ..., using loadgen.synthetic_requests(ts, count, step, users, seed) (it already exists). count >= 1, step >= 0, users >= 1, seed >= 0, all integers, else invalid arguments. A later operation's ts is never earlier than the last generated request. It returns {"requests": count, "filled": n_requests_with_a_winner, "revenue": total_charged, "by_campaign": {id: [impressions, total_charged]}}, counting only this call and listing only campaigns that won something. The hidden tests replay over ten thousand requests in one run, and the whole suite has a few seconds.
Example (quota 2500 // 24 = 104; each win costs the 50 reserve)
[["campaign", 0, "p", 100, 2500, ["home"], [0, 24], 1000],
["request", 10, "u1", "home"], ["request", 20, "u1", "home"], ["request", 30, "u1", "home"],
["request", 40, "u1", "home"],
["pacing", 41, "p"],
["pacing", 3600, "p"],
["request", 3600, "u1", "home"]]returns true, three wins at 50, then null (150 >= 104), then {"hour": 0, "quota": 104, "hour_spent": 150, "throttled": true}, {"hour": 1, "quota": 102, "hour_spent": 0, "throttled": false} ((2500 - 150) // 23), and a win at 50.
Traffic example
[["campaign", 0, "t1", 200, 1000000, ["home", "search", "feed"], [0, 24], 2],
["campaign", 0, "t2", 100, 1000000, ["home"], [0, 24], 5],
["traffic", 1000, 6, 10, 2, 1]]generates (u0, feed) (u0, search) (u0, home) (u1, search) (u1, search) (u0, feed) and returns {"requests": 6, "filled": 5, "revenue": 250, "by_campaign": {"t1": [4, 200], "t2": [1, 50]}}.
Part 3 — "You only pay for what you have left" and mid-day edits (ADS-2260)
Goal: sales changed the budget terms, and advertisers now edit campaigns while they run. Update the rules (this replaces rule 6 of "How an ad request is decided"), and add an edit operation.
New budget rules
- A campaign bids whenever
remaining > 0, even ifremainingis less than its bid. It still bids its fullbid, so it can win and it counts as runner-up for pricing. - The clearing price is computed exactly as before. The campaign is charged
min(price, remaining): when it wins with less than the price left it pays only what is left, and that leavesremaining = 0, so it takes no part in auctions until the next day.requeststill reports the fullprice, andchargedshows what was billed. The impression is served and recorded for the user either way.spent_todayand the hourly pacing spend increase bycharged. - Pacing (Part 2) still applies on top of rule 1.
Editing a running campaign
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
edit_campaign | id, changes | true | invalid arguments, campaign not found, invalid bid, invalid budget, invalid frequency cap |
changesis an object with one or more ofbid,daily_budget,freq_cap. Anything else (not an object, empty, an unknown field) isinvalid arguments. Values follow the same validation ascampaign. Errors are reported in the order listed, and a rejected edit changes nothing, even if some of its fields were valid.- Edits apply to the very next request.
daily_budgetis the campaign's budget for the current day as well as future days:remainingis recomputed asnew_budget - spent_today(not below 0). Already-served impressions still count towardsfreq_cap(changing the cap does not forget them). - Pacing restart. An edit that raises
daily_budget(new > old) restarts pacing for the current hour: from that moment the campaign'shour_spentis 0 andspent_before_hourequals everything it has spent today, soquota = (new_budget - spent_today) // (24 - h). Lowering the budget, leaving it unchanged, or editing onlybidorfreq_capdoes not touch the hour's spend.
Example 1: partial charge. All requests are in hour 23, where the quota equals the whole remaining budget.
[["campaign", 0, "q", 300, 400, ["home"], [0, 24], 1000],
["campaign", 0, "r", 100, 10000, ["home"], [0, 24], 1000],
["request", 82800, "u1", "home"], ["request", 82801, "u2", "home"], ["request", 82802, "u3", "home"],
["request", 82803, "u4", "home"], ["request", 82804, "u5", "home"],
["stats", 82805, "q"]]returns true, true, three times {"campaign": "q", "price": 101, "charged": 101}, then {"campaign": "q", "price": 101, "charged": 97} (only 97 was left), then {"campaign": "r", "price": 50, "charged": 50} (q has nothing left) and {"impressions": 4, "spent_today": 400, "remaining": 0}.
Example 2: raising the budget restarts pacing.
[["campaign", 0, "e", 100, 2400, ["home"], [0, 24], 1000],
["request", 10, "u1", "home"], ["request", 20, "u1", "home"], ["request", 30, "u1", "home"],
["edit_campaign", 40, "e", {"daily_budget": 4800}],
["pacing", 41, "e"],
["request", 50, "u1", "home"], ["request", 60, "u1", "home"], ["request", 70, "u1", "home"],
["request", 80, "u1", "home"], ["request", 90, "u1", "home"]]Quota is 100, so after two wins the third request at ts 30 returns null. The raise gives {"hour": 0, "quota": 195, "hour_spent": 0, "throttled": false} ((4800 - 100) // 24), and four more wins at 50 follow (150 < 195 still bids; 200 does not), then null at 90.
Notes
validation.py,catalog.py,targeting.pyandclock.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones except where Part 3 says it replaces a rule; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["campaign",0,"c1",200,100000,["home"],[0,24],1],["campaign",0,"c9",150,100000,["home"],[0,24],5],["campaign",0,"c10",150,100000,["home"],[0,24],5],["request",100,"u1","home"],["request",200,"u1","home"],["request",300,"u2","feed"],["request",86499,"u1","home"],["request",86500,"u1","home"]]
- Output:
- [true,true,true,{"campaign":"c1","price":151,"charged":151},{"campaign":"c9","price":150,"charged":150},null,{"campaign":"c9","price":150,"charged":150},{"campaign":"c1","price":151,"charged":151}]
- Explanation
- c1 wins at the runner-up's 150 + 1. u1 hits c1's cap of 1, so the tied c9/c10 compete; c9 was created first and pays its own bid (150 + 1 is capped). c1's impression at 100 is still in the window at 86499 and has expired at 86500.
Example 2
- Input:
- [["campaign",0,"p",100,2500,["home"],[0,24],1000],["request",10,"u1","home"],["request",20,"u1","home"],["request",30,"u1","home"],["request",40,"u1","home"],["pacing",41,"p"],["pacing",3600,"p"],["request",3600,"u1","home"]]
- Output:
- [true,{"campaign":"p","price":50,"charged":50},{"campaign":"p","price":50,"charged":50},{"campaign":"p","price":50,"charged":50},null,{"hour":0,"quota":104,"hour_spent":150,"throttled":true},{"hour":1,"quota":102,"hour_spent":0,"throttled":false},{"campaign":"p","price":50,"charged":50}]
- Explanation
- Hour 0 quota is 2500 // 24 = 104. Three reserve-price wins reach 150, so the fourth request is throttled. In hour 1 the quota is re-split from what is left: (2500 - 150) // 23 = 102.
Example 3
- Input:
- [["campaign",0,"q",300,400,["home"],[0,24],1000],["campaign",0,"r",100,10000,["home"],[0,24],1000],["request",82800,"u1","home"],["request",82801,"u2","home"],["request",82802,"u3","home"],["request",82803,"u4","home"],["request",82804,"u5","home"],["stats",82805,"q"]]
- Output:
- [true,true,{"campaign":"q","price":101,"charged":101},{"campaign":"q","price":101,"charged":101},{"campaign":"q","price":101,"charged":101},{"campaign":"q","price":101,"charged":97},{"campaign":"r","price":50,"charged":50},{"impressions":4,"spent_today":400,"remaining":0}]
- Explanation
- q keeps winning at 101 until only 97 is left; that last impression is billed 97 and q stops for the day, so r takes the next request at the reserve.
Guidelines
- Python 3 standard library only
- All money is integer cents; there is no floating-point arithmetic anywhere
- Timestamps are integer seconds and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 2 inputs replay over ten thousand generated ad requests in a single run