Search ads: spec
Table of Contents
1. Purpose
Search ads run a keyword auction over a campaign catalog for a query:
match (broad / phrase / exact), exclude on negative keywords, drop
invalid bids, rank by ad-rank, and clear each slot at a price capped
by the winner's max CPC. Module: wal-sh.adtech.search-ads.core
(pure) + ...browser (DOM).
ORACLE: src/wal_sh/adtech/search_ads/core.cljc (pure),
browser.cljs (window._searchAds).
2. Requirements
Behavioral guarantees:
- Totality: every public fn returns; no exceptions on adversarial inputs.
- Determinism: pure on inputs; no
rand, nonow, no env reads in core. - Monotonicity (in bid): bid-raise on a campaign that already ranked → same-or-better position.
- Idempotency:
(= (auction q o) (auction q o))∀q, o. - Render safety: all user-controlled strings (
headline,description,display-url,campaign-id) are HTML-escaped before injection.
Input / output domains:
query: string (nil/non-string/empty →[]).:max-cpc: finite double ≥ 0. Inf / NaN / negative → campaign dropped.:quality-score(catalog field, JS-compat): integer ∈[0,10]; OOR → dropped.:reserve-price,:epsilon: positive doubles; defaults 0.01.:max-results: positive int; default 3.
Spec note (tightening): PLANS.org defines quality-score as a
3-factor composite (CTR × landing-page × relevance). The JS catalog
stores it as a single int 1..10. We expose BOTH: the quality-score
fn for the composite (per PLANS), and the integer :quality-score
field on the campaign row (per JS). The ad-rank calc uses the
integer field for JS-parity, not the composite. Documented for
downstream callers.
3. Contract signature
Pure (core.cljc):
| Fn | Args | Returns | Pre / post |
|---|---|---|---|
(auction query) / (auction query opts) |
query: string, opts: {:max-results int :reserve-price num :epsilon num :campaigns coll :negative-keywords coll} |
vec of {:position 1-indexed-int, :campaign m, :ad-rank ≥0, :actual-cpc 0≤c≤max-cpc, :ad? true} |
Pre: none; totally defined on any input. Post: count ≤ max-results; entries sorted desc by :ad-rank; :actual-cpc ≤ :max-cpc of winner. |
(ad-rank c r) |
campaign map, relevance r ∈ [0,1] |
≥ 0 (finite double) |
Returns 0.0 on any non-finite input (NaN, Inf). |
(quality-score factors) |
{:ctr :landing-page :relevance} each in [0,1] |
∈ [0,1] |
Total. Out-of-range / NaN factors clamp to [0,1]; missing → 0. |
(keyword-relevance c q) |
campaign, query string | ∈ [0,1] |
0 on nil/empty query or no keywords. |
(matches-broad? c q) / matches-phrase? / matches-exact? |
campaign, query | boolean | Total over (any, any). Non-string q → false. |
(excluded-by-negatives? neg-kws q) |
coll-of-strings, query | boolean | Total. Substring (broad) exclusion. |
(valid-bid? c) |
campaign map | boolean | True iff :max-cpc finite ≥ 0 AND :quality-score finite ∈ [0,10]. |
(escape-html s) |
any (coerced via str) |
string | Output contains no raw <, >, ", '; & only in entity refs. |
Browser surface: window._searchAds (set by browser/init!) exposes:
{ campaigns : Array, // clj->js campaign rows
query : (q [, max]) → Array, // run auction, return JS array
render : (results, container-id) → DOMElement,
show : (q) → Array, // auction + render combined
escapeHtml: (s) → String,
keywords : (s) → Array<String> }
4. Invariants (property tests)
- ∀ valid
opts,query:(auction query opts)is avector?(totality). - ∀ entry
e ∈ slate:0 ≤ :actual-cpc e ≤ :max-cpc (:campaign e)(Vickrey cap). - ∀ permutations
πof:campaigns:(auction q (assoc opts :campaigns (π cs)))returns IDs in the same order (deterministic tiebreaks:[-ad-rank, -max-cpc, :campaign-id]). - ∀ campaign
c: raising(:max-cpc c)never demotescfrom a previously-occupied slot (monotonic in bid). - Idempotency: pure; same inputs always return
=outputs (no atoms, no time, no rand).
5. Negative-state catalog (property tests)
Failure modes the implementation must reject or handle safely. Each row maps input → expected behavior → the property test that proves it.
Disambiguation: "negative keywords" in search-ads parlance are EXCLUDE filters (advertiser opts out of queries containing a term). They are not negative test cases. This catalog covers genuine adversarial / boundary inputs.
| # | Input | Expected behavior | Property test |
|---|---|---|---|
| 1 | Non-string query (nil, int, vec, map, NaN) |
(auction) returns [] (no exception) |
non-string-query-empty (50 cases over 5 bad shapes) |
| 2 | Empty string query |
Returns [] |
empty-query-empty (30 cases) |
| 3 | Empty :campaigns, or all campaigns filtered out |
Returns [] |
covered by sub-reserve-bids-excluded (reserve so high every campaign fails) |
| 4 | All bids below :reserve-price |
Empty slate (filter runs before auction) | sub-reserve-bids-excluded (50 cases) |
| 5 | NaN :max-cpc / Infinity :max-cpc / negative :max-cpc |
Campaign dropped silently by valid-bid?; never appears in output |
invalid-bids-rejected (30 cases × 5 poisoned shapes) |
| 6 | :quality-score outside [0,10] (negative or huge) |
Campaign dropped by valid-bid? |
invalid-bids-rejected |
| 7 | Query contains a negative keyword | Entire slate empty regardless of relevance/QS | negative-keyword-blocks-everything (30 cases) |
| 8 | Duplicate :campaign-id rows |
Deterministic dedup; first occurrence wins, slate has no duplicate IDs | duplicate-ids-deduped (30 cases) |
| 9 | quality-score factors NaN / Inf / OOR (e.g. :ctr -5.0) |
Total fn: clamps to [0,1], never throws |
quality-score-total-and-bounded (100 cases) |
| 10 | :headline / :description / :display-url contains XSS payload (<script>, ', ", &) |
Render layer escapes all 5 entities; browser injects no raw HTML-significant chars from user data | escape-html-neutralizes (100 random strings) |
| 11 | Zero-QS winner in last slot | Clearing math uses (else ε) branch instead of /0; no Infinity=/=NaN leak |
covered by cpc-capped-at-max-cpc (≤ max-cpc → finite) |
Decisions flagged:
- Single bid with QS=0:
valid-bid?accepts QS=0 (matches JS) butad-rank = 0, so the entry is filtered before sorting. Effectively shown only if reserve also allows; in practice never shown. JS behaviour preserved. reserve-pricesemantics: inclusive of equal;:max-cpc >reserve= passes; only strictly-below is rejected. Matches the(>)= predicate in the implementation; documented in theauctiondocstring.- Negative-keyword match: broad (substring), not phrase. A negative
keyword
"free"blocks queries"free trial"AND"freelance". Future improvement: emitphrasenegatives if false-positives accumulate.
6. Related literature
- The per-slot clearing price capped at the winner's own bid is the Vickrey second-price rule (Vickrey 1961); the ad-rank ordering with quality-score weighting is the generalized second-price auction of Edelman, Ostrovsky and Schwarz (Edelman, Ostrovsky, and Schwarz 2007).
- Google's 2019 move to first-price (Google Ad Manager Team 2019) is the
industry direction this module deliberately does not follow; the
:actual-cpc ≤ :max-cpcinvariant is the second-price posture.
7. Cross-references
- ab-engine/spec.org: the auction-disclosure experiment
(
exp-104) that surfaces bid arithmetic to the reader. - pricing-engine/spec.org: the sibling module where a floor binds on a price; here the reserve binds on a bid.
- pocket-es-integration/spec.org: the search surface the auctioned slate is interleaved into.
- ORACLE:
src/wal_sh/adtech/search_ads/core.cljc(pure)src/wal_sh/adtech/search_ads/browser.cljs(window._searchAds)test/wal_sh/adtech/search_ads/core_prop_test.cljc(properties)
8. Open questions
- Phrase negatives. Broad (substring) negative-keyword matching blocks
"freelance"on a"free"negative; aphrasenegative type is the known fix if false positives accumulate. - Composite vs integer quality score.
ad-rankuses the integer catalog field for JS parity; switching to the 3-factor composite would change every ranking and is a breaking change for downstream callers.