Election 2027
← All documents

Votewise Ad Engine — Selection Logic Specification

Reference implementation: src/server/ads/select-ad.ts and 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

QuantityUnitWhy
Stored budgets/spendkobo (BigInt)Exact ledger
Redis budget countersmillikobo (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
BidcpmBidKoboWhat 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.spentKobo and LedgerEntry;
  • releases reservations that expired without a beacon;
  • moves campaigns to BUDGET_EXHAUSTED / COMPLETED and 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

  1. Validate slot_type against the active AdSlot keys. An unknown key returns 404.
  2. Validate geo_lga. It must belong to geo_state; if it does not, drop it and keep only the state.
  3. Cross-check with the edge-supplied state from the cf-region-code header. If the client's state ≠ edge state and geo_confidence = ip_state, use the edge state. A declared location wins because people browse from Lagos about their home LGA.
  4. 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_MODE is on and the slot's pageContext is tracker or results, 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:

#FilterRule
1Flightnow < startTs or now ≥ endTs
2Exclusionsany ancestor ∈ ad:excl:{cid}
3Political slot policyisPolitical && !slot.allowPolitical
4ARCONisPolitical && !arconNo (this is a defensive check, because a DB constraint already prevents it)
5Silence windowisPolitical 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
6Context exclusionctx_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
7Budgetad:bud:{cid}:total < costPerImp or ad:bud:{cid}:d:{today} < costPerImp
8Frequency capad:freq:{uid}:{cid}:{today} ≥ freqCap
9FloorcpmBid < 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:

  1. checks total ≥ cost, daily ≥ cost and freq < cap;
  2. DECRBY total and daily, and INCR freq with a TTL;
  3. returns 1, or 0 with 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 to reviewStatus = APPROVED and format = 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 Redis SET NX and 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:

  1. A national sponsorship for the slot.
  2. A civic house ad (for example, "Find your polling unit", "Is your PVC ready?").
  3. 204 No Content, and the client collapses the slot.

Step 12 — Events and reconciliation

  • POST /api/v1/ads/events carries {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:

CampaignTargetCPM (₦)MatchPacingScoreResult
A — Rep candidate XLGA LA-IKJ2,500LGA ×1.20on track ×1.0300,000eligible
B — Gov candidate YSTATE LA1,800STATE ×1.05behind ×1.6302,400eligible
C — Senate candidate ZSD Lagos West2,000———ARCON pending → dropped
D — Telco brandNATIONAL800NATIONAL ×1.0×1.080,000eligible
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-library page.
  • 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.