Votewise Ad Engine — Selection Logic Specification
Reference implementation: and src/server/ads/select-ad.ts.reserve-budget.lua
0. Goals and constraints
- p99 under 25 ms at the ad-server and no Postgres access on the hot path. Everything comes from Redis.
- No overspend. Budget is reserved atomically before a creative is returned, so total overspend is at most the in-flight reservations, which expire.
- Compliance is a hard filter, not a ranking signal. ARCON approval, the campaign silence window and the political flag on a slot are checked before any scoring.
- Geography is the only targeting signal. There is no behavioural or inferred-political targeting (NDPA sensitive data).
1. Units
| Quantity | Unit | Why |
|---|---|---|
| Stored budgets/spend | kobo (BigInt) | Exact ledger |
| Redis budget counters | millikobo (integer) | A ₦1,500 CPM is 150,000 kobo / 1000 = 150 kobo per impression. Low CPMs give fractional kobo, so millikobo keeps everything as integers |
| Bid | cpmBidKobo | What advertisers enter (₦/1000) |
2. Serving index (written by campaign-svc, read by ad-server)
When a campaign becomes ACTIVE, or changes, campaign-svc publishes:
{cid} below is a literal Redis Cluster hash tag, so every per-campaign key lands in the same slot and the Lua reservation stays atomic.
ad:camp:{cid} HASH { advertiserId, isPolitical, pricing, cpmMk, priority,
startTs, endTs, pacing, freqCap, electionId,
excludedCandidates(json), paidForBy, arconNo }
ad:camp:{cid}:creatives HASH { creativeId: json(creative incl. weight) }
ad:idx:{slotKey}:{level}:{geoId} SET { cid, ... } -- one key per (slot × include target)
ad:excl:{cid} SET { "LGA:LA-IKJ", ... } -- exclude targets
ad:bud:{cid}:total STRING remaining millikobo
ad:bud:{cid}:d:{yyyymmdd} STRING remaining millikobo today (TTL 48h)
ad:spon:{slotKey}:{geoKey} HASH sponsorship currently live (TTL = endAt)
ad:silence:{level}:{geoId} STRING "1" with TTL = until polls close
ad:freq:{cid}:{viewer}:{yyyymmdd} STRING count (TTL 26h)
ad:curve:{slotKey} HASH hourWAT → cumulative traffic fraction (pacing)
ad:killswitch:{level}:{geoId} STRING political kill switch
A minute-level reconciler reads the impression stream, then:
- writes the confirmed spend to
AdCampaign.spentKoboandLedgerEntry; - releases reservations that expired without a beacon;
- moves campaigns to
BUDGET_EXHAUSTED/COMPLETEDand removes them from the index.
3. Request → response algorithm
Input: GET /api/v1/ads/serve?geo_state=LA&geo_lga=LA-IKJ&slot_type=candidate_feed_native&ctx_candidate=<id>&n=1
Step 1 — Normalise and resolve geography
- Validate
slot_typeagainst the activeAdSlotkeys. An unknown key returns404. - Validate
geo_lga. It must belong togeo_state; if it does not, drop it and keep only the state. - Cross-check with the edge-supplied state from the
cf-region-codeheader. If the client's state ≠ edge state andgeo_confidence = ip_state, use the edge state. A declared location wins because people browse from Lagos about their home LGA. - Expand to the ancestor chain using the in-memory geo map (774 LGAs, loaded at boot, about 200 KB):
LGA LA-IKJ → [ LGA:LA-IKJ, FEDERAL_CONSTITUENCY:24-FC-Ikeja, SENATORIAL_DISTRICT:24-SD-Lagos-West(*), STATE:LA, NATIONAL:NG ]
(*) Wards and state constituencies are included when the client sends geo_ward. The federal constituency is included from the LGA alone only when that LGA maps to exactly one FC. Split LGAs (e.g. Surulere → Surulere I/II, Mushin, Oshodi-Isolo, Lagos Island) need geo_ward to resolve it.
Step 2 — Hard stops (global)
- If
ELECTION_DAY_MODEis on and the slot'spageContextistrackerorresults, go to Step 3 only (sponsorships). The CPM auction is skipped. - If the slot requires consent-free serving (anonymous user, no cookie), serving still works. Frequency caps then key on a short-lived device hash instead of
uid.
Step 3 — Sponsorship lock (flat-fee, exclusive)
Look up ad:spon:{slot}:{geoKey} from the most specific to the least specific level: LGA → State → NG. The first hit wins and is returned immediately with pricing: FLAT. Flat sponsorships do not consume CPM budget, and the impression is logged for reporting only.
If the sponsorship is political, the silence-window check in Step 5 still applies.
Step 4 — Candidate retrieval
candidates = SUNION( ad:idx:{slot}:{level}:{geoId} for each ancestor )
Record, for each campaign, the most specific level that matched (matchLevel). It is used for floor validation and the specificity bonus.
Step 5 — Eligibility filters (in this order, cheapest first)
A campaign is dropped if any of the following is true:
| # | Filter | Rule |
|---|---|---|
| 1 | Flight | now < startTs or now ≥ endTs |
| 2 | Exclusions | any ancestor ∈ ad:excl:{cid} |
| 3 | Political slot policy | isPolitical && !slot.allowPolitical |
| 4 | ARCON | isPolitical && !arconNo (this is a defensive check, because a DB constraint already prevents it) |
| 5 | Silence window | isPolitical and ad:silence:{level}:{geoId} exists for any ancestor. The window is set by a scheduler at pollsOpen − 24h for the election's state (or nationally for a general election) and lasts until pollsClose |
| 6 | Context exclusion | ctx_candidate ∈ excludedCandidates, or the slot is a candidate-profile slot whose page owner bought a profile takeover, in which case only the owner's campaigns are eligible |
| 7 | Budget | ad:bud:{cid}:total < costPerImp or ad:bud:{cid}:d:{today} < costPerImp |
| 8 | Frequency cap | ad:freq:{uid}:{cid}:{today} ≥ freqCap |
| 9 | Floor | cpmBid < SlotFloor[slot][matchLevel] (a defensive check, because booking already validates it) |
costPerImp (millikobo) = cpmBidKobo × 1000 / 1000 = cpmBidKobo millikobo. Put another way, a CPM in kobo equals the per-impression cost in millikobo, which is why that unit was chosen.
Step 6 — Pacing gate (EVEN pacing only)
Even pacing prevents a daily budget from being burnt in the 7–9 am peak.
expectedFraction = trafficCurve[slot][hourWAT] // cumulative share of the day's traffic by now, learned from the last 14 days
spentFraction = 1 − dailyRemaining / dailyBudget
if spentFraction > expectedFraction:
p = max(0.05, expectedFraction / spentFraction) // ahead of schedule → throttle
serve only with probability p
pacingBoost = clamp(expectedFraction / max(spentFraction, 0.01), 0.5, 2.0)
ASAP campaigns get pacingBoost = 1 and are never throttled.
Step 7 — Scoring
eCPM = cpmBidKobo // CPM model
specificity = { WARD: 1.30, STATE_CONSTITUENCY: 1.25, LGA: 1.20, FEDERAL_CONSTITUENCY: 1.15,
SENATORIAL_DISTRICT: 1.10, STATE: 1.05, NATIONAL: 1.00 }[matchLevel]
quality = clamp(smoothedCTR / slotAvgCTR, 0.7, 1.3) // Bayesian-smoothed; new campaigns start at 1.0
score = eCPM × specificity × quality × pacingBoost + priority × 10^9
priority is an admin override. House ads are -100, so they only win when nothing else is eligible. A future paid "guaranteed" tier can sit above the auction.
Step 8 — Selection
- Take the top 5 by score and pick one by weighted random selection, with weight equal to the score. This avoids "winner takes 100%" starvation, which matters when rival candidates in the same race buy the same LGA. It also lets the slot rotate fairly between equal bidders, which matters for perceived neutrality.
- Optional (behind a flag): second-price clearing. The charge is
max(floor, nextScore / (specificity × quality × pacingBoost)) + 1 kobo. The default is first-price fixed CPM, because Nigerian political buyers expect a predictable rate card.
Step 9 — Atomic reservation
Run reserve-budget.lua (one round-trip). The script:
- checks total ≥ cost, daily ≥ cost and freq < cap;
DECRBYtotal and daily, andINCRfreq with a TTL;- returns
1, or0with a reason.
If the reservation fails because another request won the race, go back to Step 8 with the remaining candidates. After at most 3 attempts, fall through.
Step 10 — Creative choice and response
- Pick a creative within the campaign by
weight(weighted random), restricted toreviewStatus = APPROVEDandformat = slot.format. - Mint an impression token:
base64url(HMAC-SHA256(secret, cid|crid|slot|lga|costMk|ts|nonce))plus the payload. The beacon endpoint verifies it, so impressions cannot be forged to drain a rival's budget. Each nonce is single-use, enforced with a RedisSET NXand a 1-hour TTL. - Political creatives must be returned with the disclosure block:
"Political Ad · Paid for by {paidForBy} · ARCON {arconNo}". The frontend component refuses to render a political ad without it.
Step 11 — Fallback chain
When nothing is eligible, try in order:
- A national sponsorship for the slot.
- A civic house ad (for example, "Find your polling unit", "Is your PVC ready?").
204 No Content, and the client collapses the slot.
Step 12 — Events and reconciliation
POST /api/v1/ads/eventscarries{token, type: viewable|click}. A "viewable" event means at least 50% of pixels were visible for at least 1 s (IAB), measured client-side with IntersectionObserver.- Billing happens on viewable impressions. Reservations that receive no viewable beacon within 10 minutes are refunded to Redis by the reconciler (
INCRBY). Advertisers pay only for ads that were seen, which is a sales point. - Clicks redirect through
/r/{token}, which uses a 302 and logs the click. Click spam from one device hash beyond 3 per campaign per hour is ignored.
4. Worked example
User in Ikeja LGA (Lagos) viewing the Ikeja House of Reps race page, slot candidate_feed_native:
| Campaign | Target | CPM (₦) | Match | Pacing | Score | Result |
|---|---|---|---|---|---|---|
| A — Rep candidate X | LGA LA-IKJ | 2,500 | LGA ×1.20 | on track ×1.0 | 300,000 | eligible |
| B — Gov candidate Y | STATE LA | 1,800 | STATE ×1.05 | behind ×1.6 | 302,400 | eligible |
| C — Senate candidate Z | SD Lagos West | 2,000 | — | — | — | ARCON pending → dropped |
| D — Telco brand | NATIONAL | 800 | NATIONAL ×1.0 | ×1.0 | 80,000 | eligible |
| Sponsorship | — | — | none for this slot | — | — | skip |
Weighted pick among {A, B, D}: A ≈ 44%, B ≈ 44%, D ≈ 12%. Suppose B wins. Lua reserves 180,000 millikobo (₦1.80), and the response includes a "Political Ad · Paid for by …" disclosure.
5. Guardrails beyond code
- Equal-access rate card. Political floor prices are published, and the same price applies to every party for the same slot, geo and date. This protects regulatory and reputational standing.
- Political ad archive. Every political creative, its spend band, its targeted geos and its flight dates are shown in a public
/ad-librarypage. - No targeting of individuals. Rejected targeting features include custom audiences, retargeting and "users who viewed candidate X".
- Kill switch.
ad:killswitch:{scope}disables all political serving nationally or per state within seconds, for use on INEC or court direction.