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, no now, 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 a vector? (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 demotes c from 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) but ad-rank = 0, so the entry is filtered before sorting. Effectively shown only if reserve also allows; in practice never shown. JS behaviour preserved.
  • reserve-price semantics: inclusive of equal; :max-cpc > reserve= passes; only strictly-below is rejected. Matches the (>)= predicate in the implementation; documented in the auction docstring.
  • Negative-keyword match: broad (substring), not phrase. A negative keyword "free" blocks queries "free trial" AND "freelance". Future improvement: emit phrase negatives 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-cpc invariant 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; a phrase negative type is the known fix if false positives accumulate.
  • Composite vs integer quality score. ad-rank uses 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.
Edelman, Benjamin, Michael Ostrovsky, and Michael Schwarz. 2007. “Internet Advertising and the Generalized Second-Price Auction: Selling Billions of Dollars Worth of Keywords.” American Economic Review 97 (1): 242–59. https://doi.org/10.1257/aer.97.1.242.
Google Ad Manager Team. 2019. “Rolling out First Price Auctions to Google Ad Manager Partners.” Google Blog. https://blog.google/products/admanager/rolling-out-first-price-auctions-google-ad-manager-partners/.
Vickrey, William. 1961. “Counterspeculation, Auctions, and Competitive Sealed Tenders.” The Journal of Finance 16 (1): 8–37. https://doi.org/10.1111/j.1540-6261.1961.tb02789.x.