Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Why Promovolve?

The Problem: Programmatic Advertising Wasn’t Built for Publishers

The modern programmatic advertising stack — SSPs, DSPs, exchanges, DMPs — was designed to solve a problem for large advertisers: how to reach the right user across millions of websites in real time. It succeeded spectacularly at that. But in doing so, it created a system that works against the interests of most publishers.

Publishers lost control of their own inventory

When a user visits a publisher’s site, the ad decision happens somewhere else entirely. The publisher’s SSP fires a bid request to an exchange, which broadcasts it to dozens of DSPs, which consult their user profiles, run their bidding algorithms, and return a response — all within 100 milliseconds. The publisher never sees the bids. They never choose which ad runs. They receive a creative URL and a clearing price, and they serve it.

This architecture optimizes for advertiser reach, not publisher value. The publisher’s content — the reason the user is there — is reduced to a signal in someone else’s targeting model. A carefully researched article about travel in Kyoto and a clickbait slideshow about celebrity gossip are, to the exchange, just two different URLs carrying a user with a cookie.

Ads became something people hate

Nobody hates ads in a travel magazine. But people install ad blockers, pay for premium subscriptions to avoid ads, and describe web advertising as the worst part of the internet. What changed?

The difference isn’t the existence of ads — it’s what they became. A magazine ad for hiking boots next to a trail guide feels natural. A web ad for the shoes you looked at yesterday, following you to an unrelated news article, feels invasive. The first is a recommendation in context. The second is surveillance.

Traditional ad tech targets users, not content. A user who visited a car dealership website yesterday gets retargeted with car ads on every site they visit today — a cooking blog, a news site, a forum. The publisher’s content is irrelevant. The ad is chasing the user. The result is an experience that feels wrong to everyone involved: readers feel stalked, publishers lose control of what appears on their pages, and advertisers pay to annoy people in the wrong context.

This isn’t a privacy problem to be solved with consent banners and cookie policies. It’s a design problem. Web advertising chose to target people instead of content, and in doing so, it turned ads from something readers could tolerate — even appreciate — into something they actively resist.

Small and medium publishers are left behind

The programmatic stack has enormous fixed costs. Integrating with an SSP requires technical sophistication. Meeting exchange quality thresholds requires minimum traffic volumes. The revenue share stacks up: the exchange takes a cut, the SSP takes a cut, the DSP takes a cut, the DMP takes a cut. Each intermediary skims from the transaction, and the publisher receives whatever is left.

For niche publishers — a Japanese travel blog, a specialized cooking site, a local news outlet — the economics don’t work. Their traffic is too small for exchanges to care about. Their content is too specific for broad behavioral targeting. Their readers are too privacy-conscious for invasive tracking. These publishers either accept pennies from bottom-tier ad networks, plaster their sites with low-quality ads, or give up on monetization entirely.

The latency tax

Real-time bidding imposes a latency floor on every page load. The browser must wait for the ad request, the exchange round-trip, and the creative download before the ad slot renders. On a fast site, the ads are the slowest thing on the page. Header bidding — the industry’s attempt to improve publisher yield — made this worse by running multiple auctions sequentially or in parallel, each adding network round-trips.

Users notice. Ad-blocker adoption correlates directly with page load degradation caused by ad tech. Publishers who care about user experience are penalized for participating in the system that’s supposed to monetize that experience.

The Opportunity: What Magazines Got Right

Pick up any well-produced magazine — a travel magazine, a cooking publication, an architecture journal. Look at the ads. They’re relevant. A travel magazine shows ads for airlines, luggage, and hotels. A cooking magazine shows kitchen equipment, specialty ingredients, and culinary schools. The ads feel like they belong there. They complement the content instead of interrupting it.

And people actually looked at them. When ads weren’t cluttered, when they weren’t fighting for attention with pop-ups and auto-playing videos, when there were just a few well-placed ads that matched what you were reading — you noticed them. Sometimes you tore out a page and kept it. The ads had value because they were relevant, restrained, and respectful of the reader’s attention.

Nobody found these ads creepy. Nobody wondered how the magazine knew they were interested in travel. The answer was obvious: you bought a travel magazine. The content was the signal.

This model worked for over a century. Advertisers paid a premium for placement in publications whose readers matched their audience. Publishers curated which ads appeared, maintaining quality and relevance. Readers accepted ads as part of the experience — sometimes even valued them — because the ads were for things they actually cared about, presented in a way that respected the editorial context.

Then the internet threw it all away.

Instead of matching ads to content, the web advertising industry decided to match ads to users. Instead of asking “what is this person reading?” it asks “who is this person and what have they done?” The content of the page became irrelevant. The reader became a target. And the entire experience — for publishers, readers, and even most advertisers — got worse.

Promovolve is an attempt to get back what magazines had. Relevant ads, matched to content, with no tracking, no surveillance, and no degradation of the reading experience.

Why now?

Content-based advertising isn’t a new idea, but doing it automatically at web scale was impractical until recently. Classifying page content into advertising categories required either manual curation (expensive) or primitive keyword matching (inaccurate).

LLMs changed this. A single API call to Gemini Flash costs a fraction of a cent and returns IAB Content Taxonomy categories with confidence scores — accurately classifying a page about hiking in the Japanese Alps into Travel, Outdoor Recreation, and Asia destinations. This wasn’t economically viable five years ago. It is now.

What Promovolve Does Differently

Most ad-tech projects keep the same ad format — a fixed-size IAB rectangle delivered through real-time bidding — and try to fix the parts around it: better targeting, faster auctions, friendlier consent flows. Promovolve takes a different bet. It changes the format itself, then rebuilds the rest of the stack to fit.

The ad isn’t a rectangle. It’s a magazine spread.

A Promovolve creative isn’t a static banner. It’s a small magazine — a sequence of pages a reader can expand into a full-screen overlay, swipe through, and collapse again. The collapsed view sits unobtrusively in the publisher’s slot like a magazine ad on a page. Tapped, it opens into editorial-style content: cover, story pages, a call to action.

This is closer to how print advertising worked. The reader chooses to engage. The advertiser gets attention when it’s wanted, not interruption when it isn’t. And because the format is a container — not a fixed pixel size — a single creative can render in any slot the publisher offers.

Readers can dog-ear an ad

Pick up a magazine, fold the corner of an interesting page, come back to it later. Promovolve gives readers the same affordance for ads. A reader who finds a creative interesting can fold its corner — a literal dog-ear — and the next time they land on a page where that advertiser is eligible, the ad they bookmarked is the one they see. The pin lives in the reader’s browser, not in a server-side profile.

Nothing in traditional ad tech does this. Bookmarks are something you do for content you care about, and ads have never been treated as content readers might want to keep. The dog-ear is a bet that some ads are worth coming back to, and that letting readers say so out loud is a better signal than retargeting them with the same creative for the next thirty days.

Creatives flow to fit the slot

Traditional creatives are pinned to IAB pixel dimensions: 300×250, 728×90, 970×250. Mismatched slots get letterboxing, scaling artifacts, or no fill at all. Promovolve creatives are fluid — the same content reflows to fit whatever rectangle the publisher provides. Advertisers upload a landing page or a structured creative once; Promovolve’s pipeline (Playwright extraction, Gemini rewriting, the in-house designer) renders it into the slot’s geometry on demand.

Auctions happen before users arrive

Traditional systems run an auction on every page load because they need to evaluate the user in real time. Promovolve doesn’t need to — the content doesn’t change between page loads. So the auction runs when content is published or updated (via scheduled crawl), and the results are cached in a replicated in-memory index. When a user arrives, the ad is already chosen. Serve latency drops from 50–200ms to under 1ms.

Multiple candidates, not a single winner

Instead of picking one winner per auction, Promovolve shortlists multiple candidates per ad slot and caches them all. At serve time, Thompson Sampling selects among them, balancing exploration (trying new creatives to learn their click-through rate) against exploitation (serving the creative that performs best). The system continuously learns which ads work best on which content, without A/B test infrastructure or manual optimization.

Quality-adjusted auctions reward good creatives

Bidding the highest CPM doesn’t automatically win the slot. Promovolve scores each candidate as sampled_CTR × CPM^α, where α is a publisher-tunable weight. A creative that earns clicks can outscore a higher-bidding one that doesn’t, and the publisher controls how heavily quality counts versus price. Pricing is quality-adjusted too: an exploiting winner pays the minimum bid that would still have won given its CTR, not its own bid — so there’s no incentive to shade.

Publisher-side learning, not advertiser-side bid wars

Promovolve has no campaign-side reinforcement learning agent. With second-price quality-adjusted auctions, bid shading is counterproductive — there’s nothing for an RL agent to learn that the auction mechanism doesn’t already enforce. The reinforcement learning that does exist runs on the publisher side: a per-site agent tunes the floor CPM upward when bid spread suggests the market can bear it, and downward when fill suffers. The publisher’s revenue improves; advertisers see honest second-price clearing.

Budget pacing adapts to reality

A PI controller with self-tuning gains, traffic shape learning, and oscillation detection smooths budget delivery across the day. It learns that traffic peaks at 10am and dips at 3pm, that weekends have a different shape than weekdays, and adjusts automatically. Publishers see steady ad delivery instead of budgets that exhaust by noon and leave empty slots all afternoon.

No user tracking — because it’s not needed

Promovolve stores no user profiles, sets no tracking cookies, and collects no cross-site identifiers. Targeting is based entirely on the content of the page being viewed. This isn’t a sacrifice for privacy compliance — it’s a consequence of the design. When you match ads to content, there’s nothing about the user you need to know. The content tells you everything. Even the dog-ear lives in the reader’s own browser; the server only learns “someone saved this creative,” never who.

Who Promovolve Is For

Publishers

Promovolve is for publishers who:

  • Own their relationship with readers and won’t compromise it with invasive tracking
  • Create quality content in specific verticals where content-based targeting is naturally strong
  • Want sub-millisecond ad serving that doesn’t degrade their site performance
  • Prefer simplicity over the operational complexity of SSP/exchange integrations
  • Need fair economics without the intermediary tax of the programmatic supply chain

Advertisers — from local businesses to global brands

The traditional programmatic stack has a minimum viable scale. Setting up DSP campaigns, managing bid strategies, and meeting exchange minimums requires budgets and expertise that exclude most businesses. The vast majority of businesses in the world — the local restaurant, the neighborhood bookshop, the regional tour operator, the community event organizer — cannot participate.

Promovolve lowers the bar to zero. An advertiser is anyone with an image and a landing page. That could be:

  • A local hiking gear shop placing an ad on a regional outdoor recreation blog
  • A community festival announcing dates on a local news site
  • A cooking class promoting on a food blog in their city
  • A small hotel in Kyoto reaching readers of a travel article about their neighborhood
  • A global brand running a campaign across a network of niche publishers

There’s no DSP to integrate with. No bid strategy to configure manually — the second-price auction handles price discovery, and quality-adjusted scoring rewards creatives readers actually engage with. No user profiles to buy. Just: “here’s my ad, here’s my budget, here’s what my product is.” The advertiser picks an ad product category (e.g., “Travel” or “Kitchen Equipment”), and the system automatically derives which content categories match — using the official IAB mapping between Ad Product Taxonomy 2.0 and Content Taxonomy 2.1. The system handles the rest.

This is how magazine advertising worked. A local restaurant could buy a quarter-page in a neighborhood magazine. The scale matched the business. Promovolve brings that accessibility to the web.

Advertising Agencies

Advertising agencies don’t own publisher platforms — they manage campaigns on behalf of their clients and place ads across publishers’ sites. In the traditional programmatic world, this means agencies must navigate a maze of DSP contracts, exchange seat IDs, and platform-specific bid management tools, each taking a cut along the way.

With Promovolve, agencies can own the ad-serving infrastructure itself. An agency can run its own Promovolve instance, build a network of publisher relationships, and manage all of their clients’ campaigns through a system they control end-to-end. No DSP middleman. No exchange fees. No dependency on someone else’s platform. The agency becomes the platform.

The auction is second-price and quality-adjusted, so there’s nothing to bid-optimize against. Agencies spend their time on strategy and creative — picking the publications, choosing the placements, managing client budgets — instead of feeding a bid-management tool. It’s closer to the magazine ad sales model agencies grew up with, except now the agency owns the technology that makes it all work.

How This Book Is Organized

The rest of this book documents every algorithmic detail, derived from the source code:

  • Architecture — Pekko cluster topology, entity hierarchy, and data flow
  • Auction — The five phases of the periodic batch auction, plus quality-adjusted scoring and pricing
  • Serving — Thompson Sampling, cold start, fair selection, and dog-ear pin-honoring at serve time
  • Pacing — PI control, self-tuning, traffic shape learning
  • Distributed State — ServeIndex replication and consistency
  • Comparison — Point-by-point mapping against traditional SSP/DSP/exchange patterns

Each chapter is self-contained. All formulas, thresholds, and constants come directly from the Scala source.

Chapter 1: A Publisher Joins

Yuki runs a travel blog from Kyoto. She writes about temples, hiking trails, seasonal festivals, and hidden restaurants. Her readers are people planning trips to Kansai — engaged, curious, spending real time on each article. She publishes two or three articles a week, each one carefully researched.

She wants to monetize her site without ruining it. No pop-ups. No auto-playing video ads. No “you looked at sneakers yesterday” retargeting that has nothing to do with her content. She wants ads that feel like they belong — a ryokan in Arashiyama, a hiking gear shop, a regional train pass.

She signs up with Promovolve and adds a small JavaScript snippet to her site. It creates two ad slots: a 300×250 rectangle in the sidebar and a 728×90 banner between article sections.

That’s all she does on day one. The system takes over from here.

The Crawler Wakes Up

At 2am, Promovolve’s crawler visits Yuki’s site. It’s a Playwright-based headless browser — it renders JavaScript, scrolls the page, and extracts the visible text content. It also detects the ad slots Yuki placed and records their sizes and positions.

The crawler follows links from the homepage to recent articles, up to a configurable depth (default: 2 levels). For Yuki’s blog, that means the homepage, the article listing page, and each individual article.

For each page, the crawler captures the visible text — the article body, headings, captions. This raw text is what the system will use to understand what the page is about.

The LLM Classifies the Content

The crawler’s text goes to an LLM — by default, Google’s Gemini Flash, chosen for cost (a fraction of a cent per call). The prompt asks the model to classify the content into IAB Content Taxonomy 2.1 categories, which is the ad industry’s standard vocabulary for describing what a page is about.

For Yuki’s article “Autumn Foliage Hikes in Eastern Kyoto,” the LLM returns:

{
  "selected_taxonomy_ids": [
    {"id": "596", "confidence": 0.95},
    {"id": "564", "confidence": 0.85},
    {"id": "483", "confidence": 0.70}
  ]
}

Translated: Travel (596) with high confidence, Hiking/Camping (564), and East Asian Culture (483). The system now knows what this page is about — in a language that advertisers understand.

If the LLM is down, the circuit breaker trips after 5 consecutive failures and stops trying for 30 seconds. The page just doesn’t get classified this crawl cycle. It’ll be picked up next time. No crash, no degradation of the serving path.

Category Ranking: What Works on This Site?

The classification isn’t the end of the story. Not all categories perform equally on Yuki’s site. “Travel” ads might get a 4% click rate, while “East Asian Culture” ads get 0.5%. Over time, the system learns this.

Each (category, site) pair has a TaxonomyRankerEntity — a tiny actor that maintains a Beta distribution of click-through performance for that category on that specific site. Early on, with no data, all categories start with Beta(1, 1) — total ignorance. As impressions and clicks accumulate, the distributions sharpen.

The ranker uses Thompson Sampling (the same algorithm used at serve time — more on that later) to assign weights to each category. Categories with strong proven performance get high weights. Categories with little data get variable weights — sometimes high (exploration), sometimes low.

The top 3 categories by weight are forwarded to the auction. For Yuki’s site with some history, that might be Travel (high proven CTR), Hiking/Camping (decent CTR), and a newer category like Food & Drink (uncertain, worth exploring).

The Page Is Ready for Auction

At this point, Promovolve knows:

  • What the page is about: Travel, Hiking, East Asian Culture (from the LLM)
  • Which categories perform well on this site: Travel and Hiking (from the ranker)
  • What ad slots are available: 300×250 sidebar, 728×90 banner (from the crawler)

This information is stored in the AuctioneerEntity for Yuki’s site — an actor sharded by site ID that remembers the last classification for each URL. The page is now ready for advertisers to bid on.

Nothing has happened on the user-facing side yet. No reader has been slowed down. No ad has been shown. The auction infrastructure was built in the background, on a schedule, while Yuki and her readers were asleep.

The next chapter: an advertiser discovers Yuki’s site and wants to place an ad.


Technical deep dives: Page Classification · Category Ranking · System Architecture

Chapter 2: An Advertiser Joins

Takeshi runs a small ryokan in Hakone. It’s a family business — 8 rooms, a natural hot spring, views of the mountains. His guests are mostly travelers who found him through word of mouth or travel blogs. He wants to reach more of those readers.

He’s tried Google Ads. The interface was overwhelming. Keywords, bid strategies, quality scores, ad groups, campaign types — he spent more time learning the system than running the business. And the ads followed people around the internet: someone who Googled “Hakone ryokan” once would see his ad on cooking websites and news portals. That felt wrong.

With Promovolve, Takeshi enters his ryokan’s landing page URL, sets a daily budget of $20, a maximum CPM of $5, and selects his ad product category: Travel. The system pulls his landing page through Playwright, lets Gemini rewrite the copy into a few story-style pages, and the in-house designer renders an expandable magazine creative — cover, two interior pages with photos and rates, a final page with a “Reserve” call to action. Takeshi previews it once, approves the layout, and submits.

That’s it. No keywords. No audience targeting. No bid strategy to configure. No locked-in pixel dimensions either — the same creative will reflow into whatever slot a publisher offers. Promovolve automatically figures out which content categories match his product — articles about destinations, hiking, cultural tourism — using the official IAB mapping between ad product and content taxonomies. His ad will appear next to those articles, the exact context where someone would be interested in a ryokan.

What Happens Behind the Scenes

When Takeshi creates his campaign, several things happen in the cluster:

A CampaignEntity is born. An actor, sharded by advertiser ID and campaign ID, springs to life. It holds Takeshi’s campaign state: max CPM ($5), daily budget ($20), creative assignments, status (active), pacing buckets, and a Bloom-filter-backed spend ledger. There’s no bid optimizer or learning agent inside — quality-adjusted second-price clearing at serve time means the campaign always bids its honest CPM, and the auction extracts the right price.

The creative is stored. The rendered magazine creative — pages, layout metadata, image references — is persisted to the creative repository. Each rendered image is uploaded to R2 (Cloudflare’s S3-compatible storage), hashed by SHA-256 for deduplication, and recorded with its dimensions and MIME type. Takeshi’s landing page URL stays attached to the campaign so the call-to-action page can deep-link there.

Categories are derived. Takeshi chose “Travel” as his ad product category (IAB Ad Product Taxonomy 2.0). The system calls ContentToAdProductMapping.getContentForAdProduct() to derive the matching content categories (IAB Content Taxonomy 2.1) — a set of numeric IDs representing destinations, outdoor recreation, cultural tourism, and other content topics that match a travel product. If no direct mapping exists, it walks up the taxonomy’s parent chain until it finds one. Takeshi doesn’t need to know any of this — he just said “Travel.”

The CampaignDirectory is notified. A cluster singleton maintains a reverse index: category → set of campaigns. It registers Takeshi’s campaign under each of its derived content categories, then fans out the update to CategoryBidderEntity shards via CampaignDistributor (8 workers). Now, whenever a page is classified into one of those categories, the auction knows to ask Takeshi’s campaign for a bid.

Publisher Approval

Here’s something that doesn’t exist in traditional programmatic advertising: the publisher gets to say no.

Before Takeshi’s ryokan ad can appear on Yuki’s travel blog, Yuki reviews it. She sees the creative, the landing page, and the advertiser’s information in her publisher dashboard. She can:

  • Approve — the creative enters the ServeIndex and can be shown to readers
  • Reject — the creative is removed and the next candidate moves up
  • Flag — mark for review later

This approval workflow is why Promovolve runs multi-candidate auctions. If the auction only picked one winner and the publisher rejected it, the slot would be empty. With multiple candidates, rejecting one just promotes the next.

Yuki approves the ryokan ad. It fits her site perfectly.

Ready to Bid

Takeshi’s campaign is now in the system:

  • Magazine creative rendered and approved for Yuki’s site
  • Ad product category: Travel (content categories derived automatically)
  • Budget: $20/day, max CPM: $5
  • No bid optimizer to configure — the auction handles price discovery

The next time the AuctioneerEntity for Yuki’s site runs an auction — either from a fresh crawl or the 5-minute re-auction timer — Takeshi’s campaign will be among the bidders.

But Takeshi isn’t the only advertiser. A regional JR rail pass campaign is also targeting Travel with a $8 CPM. A hiking gear company targets Hiking/Camping at $4 CPM. A new cooking class in Kyoto targets Food & Drink at $3 CPM.

How does the system decide who gets which slot? That’s the auction.


Technical deep dives: Entity Hierarchy · Bid Collection · Candidate Shortlisting

Chapter 3: The First Auction

It’s 2:07am. The crawler has just finished classifying Yuki’s latest article — “Autumn Foliage Hikes in Eastern Kyoto.” The AuctioneerEntity for Yuki’s site receives the classification: Travel (0.95), Hiking/Camping (0.85), East Asian Culture (0.70).

Three ad slots need filling. Four campaigns are in the system. The auction begins.

Phase 1: Category Ranking

The AuctioneerEntity asks the TaxonomyRankerEntity for each category: “What’s your weight for this site?”

Each ranker samples from its Beta distribution — Thompson Sampling at the category level:

CategoryDistributionSampleRank
TravelBeta(12, 88) — proven performer0.141st
Hiking/CampingBeta(3, 47) — decent, some data0.082nd
East Asian CultureBeta(1, 1) — brand new, no data0.613rd (exploration!)

East Asian Culture ranks 3rd despite having no data — the uniform Beta(1, 1) distribution sampled high. This is exploration: the system will try this category to learn if it works on Yuki’s site. Most of the time, the established categories win. Occasionally, a new one gets a chance.

The top 3 categories advance to bidding.

Phase 2: Bid Collection

For each ranked category, the AuctioneerEntity asks the CategoryBidderEntity: “Who wants to bid on Travel for this site?”

The CategoryBidderEntity fans out to all campaigns registered for that category. Each CampaignEntity evaluates whether it should bid:

Takeshi’s Ryokan (Travel, Hiking): Budget remaining? Yes ($20). Campaign active? Yes. Creative approved for this site? Yes. Bid: $5.00 CPM.

JR Rail Pass (Travel): Budget remaining? Yes. Bid: $8.00 CPM.

Hiking Gear Co (Hiking): Budget remaining? Yes. Bid: $4.00 CPM.

Kyoto Cooking Class (Food & Drink): This campaign isn’t registered for Travel, Hiking, or East Asian Culture. It doesn’t bid.

Three bids collected. All above the floor price ($0.50). All pass eligibility: active status, positive budget, creative size matches at least one slot.

Phase 3: Fair Candidate Selection

Now the system has to assign candidates to slots. This is where Promovolve diverges from traditional auctions.

A traditional auction would give all three slots to JR Rail Pass — they bid highest. But that’s terrible for everyone: the publisher shows the same ad three times (bad UX), the other advertisers never get a chance (no exploration), and the system never learns if Takeshi’s ryokan ad might actually get more clicks.

Promovolve uses fair selection: each campaign gets one slot before any campaign gets a second.

Slot 1 (banner):  JR Rail Pass     — $8.00 CPM (highest bidder, first pick)
Slot 2 (sidebar): Takeshi's Ryokan — $5.00 CPM (second highest, one slot each first)
Slot 3 (sidebar): Hiking Gear Co   — $4.00 CPM (third)

Each slot gets multiple candidates (not just one), ordered by CPM but guaranteed to include at least one creative from each bidding campaign. This candidate list is what gets cached for serve-time selection.

Phase 4: Caching in the ServeIndex

The auction results are written to the ServeIndex — a replicated in-memory store backed by Pekko’s Distributed Data (DData).

Each slot gets an entry:

Key: "yuki-site|banner-top|bucket-12"
Value: [
  {creative: jrpass-ad, cpm: 8.00, campaign: jrpass, advertiser: jr-west, ...},
  {creative: ryokan-ad, cpm: 5.00, campaign: takeshi, advertiser: takeshi, ...}
]

Key: "yuki-site|sidebar-1|bucket-7"
Value: [
  {creative: ryokan-ad, cpm: 5.00, campaign: takeshi, advertiser: takeshi, ...},
  {creative: hiking-boots, cpm: 4.00, campaign: hikegear, advertiser: hikegear-co, ...}
]

The write is WriteLocal — it completes instantly on the node running the AuctioneerEntity. Within 2 seconds, gossip propagates the data to every other node in the cluster. Every API node now has these candidates in local memory.

The candidates have a TTL of 120 minutes. If no re-auction refreshes them, they expire and the slot goes empty. But re-auctions run every 5 minutes, so in practice candidates are always fresh.

What Just Happened

In about 4 seconds of background processing:

  1. An LLM classified the page content into advertising categories
  2. Thompson Sampling ranked those categories by historical performance on this site
  3. Eligible campaigns placed bids at their max CPM
  4. All competitive bids passed through to serve-time selection
  5. Candidates were cached in replicated memory across the cluster

No reader was involved. No page load was delayed. The entire auction happened in the background, and the results are sitting in memory, waiting.

Now a reader arrives.


Technical deep dives: Periodic Batch Auction · Why Multi-Candidate? · ServeIndex Caching

Chapter 4: A Reader Arrives

It’s 10:15am. A traveler in Singapore is planning a November trip to Kyoto and finds Yuki’s article through a search engine: “Autumn Foliage Hikes in Eastern Kyoto.” The page loads. The article renders. And in the sidebar, an ad slot needs filling.

Yuki’s JavaScript snippet fires a request to Promovolve:

GET /v1/serve?pub=yuki-site&url=https://yukiblog.jp/autumn-hikes&slot=sidebar-1

What happens next takes less than a millisecond.

Step 1: Local DData Lookup

The API node handling this request looks up yuki-site|sidebar-1 in its local DData replica. This is a hash map lookup in the JVM’s memory — no network call, no database query. The candidates from last night’s auction (refreshed by the 5-minute re-auction cycle) are right there:

Candidates:
  1. Takeshi's Ryokan — $5.00 CPM, Beta(1, 1)  [new, no impressions yet]
  2. Hiking Gear Co   — $4.00 CPM, Beta(1, 1)  [new, no impressions yet]

Both creatives were approved by Yuki. Both have budget remaining. Both are ready to serve.

Step 2: Content Recency Check

The system checks: is this page still fresh? The classification timestamp says it was crawled 8 hours ago. The recency window for Yuki’s site is 48 hours. Eight hours is well within that — the page is fresh. Proceed.

If the article were from last week and hadn’t been re-crawled, the candidates might have expired (TTL 120 minutes) and the response would be 204 No Content — an empty ad slot. This is by design: stale content doesn’t get monetized.

Step 3: The Pacing Gate

Before choosing a creative, the PI controller decides: should we serve at all?

It’s 10:15am. Takeshi’s ryokan campaign has a $20 daily budget. Based on the traffic shape the system has learned for Yuki’s site (most traffic between 8am-11am JST, another peak at 8pm), the ideal spend by 10:15am is about 35% of the budget — $7. The campaign has spent $4 so far. It’s slightly underpacing.

The PI controller computes a throttle probability: 0.12 (skip 12% of requests to stay on pace). A random number is drawn: 0.47. That’s above 0.12, so this request passes the gate.

If the campaign were overspending — say, $12 spent by 10:15am — the throttle would be higher, maybe 0.65, and more requests would be skipped. The campaign would slow down, stretching its remaining budget across the afternoon.

This happens before creative selection. If the gate rejects the request, Thompson Sampling never runs — no exploration is wasted on a throttled impression.

Step 4: Thompson Sampling

Now the system picks which creative to show. Both candidates are brand new — zero impressions, zero clicks. Their Beta distributions are both Beta(1, 1), the uniform distribution.

Yuki has her site set to Balanced (α=0.5). Thompson Sampling draws a random sample from each candidate’s Beta posterior:

Takeshi's Ryokan: sample from Beta(1, 1) → 0.72
  score = 0.72 × 5.00^0.5 = 0.72 × 2.24 = 1.61

Hiking Gear Co:   sample from Beta(1, 1) → 0.34
  score = 0.34 × 4.00^0.5 = 0.34 × 2.00 = 0.68

Takeshi’s ryokan wins this round. Not because it’s better — nobody knows yet — but because its random sample happened to be higher. Next time, the hiking gear ad might win. With Beta(1, 1) on both sides, it’s nearly a coin flip, weighted slightly by CPM.

This is pure exploration. Over the next hundred impressions, the system will learn which creative readers actually click on, and the randomness will give way to informed selection.

Step 5: Budget Reservation

Before serving the ad, the system computes the clearing price and reserves the spend. Takeshi won on quality (higher CTR sample), so the price is quality-adjusted:

Hiking Gear's losing score: 0.68
Takeshi's sampled CTR: 0.72
Payment: (0.68 / 0.72)^(1/0.5) = $0.89 CPM
Floor: $0.50
Clearing price: max($0.89, $0.50) = $0.89

Takeshi bid $5.00 but pays only $0.89 per thousand impressions. This is the quality discount — better creative quality means lower cost.

The CampaignEntity for Takeshi’s campaign receives a TryReserve request:

  • Amount: $0.89 / 1000 = $0.00089
  • Budget remaining: $16.00
  • Result: Reserved. Budget is now $15.99911.

If the budget were exhausted, the response would be InsufficientBudget, and Thompson Sampling would try the next candidate (Hiking Gear Co). If all candidates are exhausted, the response is 204 No Content. Graceful degradation, no errors.

Step 6: The Response

The API returns a JSON response in under a millisecond:

{
  "assetUrl": "https://cdn.promovolve.dev/ryokan-hakone-300x250.jpg",
  "mime": "image/jpeg",
  "width": 300,
  "height": 250,
  "clickUrl": "https://api.promovolve.dev/v1/click?pub=yuki-site&...",
  "impUrl": "https://api.promovolve.dev/v1/imp?pub=yuki-site&...",
  "creativeId": "ryokan-001",
  "version": 1711090800000
}

The JavaScript snippet renders the image and fires the impression pixel (impUrl). The tracking URL is HMAC-signed — it can’t be forged or tampered with.

What the Reader Sees

A photo of a ryokan nestled in autumn mountains, next to an article about autumn hikes in Kyoto. The ad is relevant. It fits the context. The reader might not even register it as an “ad” in the intrusive sense — it feels like a recommendation.

This is what we set out to build: the magazine experience, on the web.

The reader reads the article. They notice the ryokan photo. They think, “that looks nice for our trip.” They click.


Technical deep dives: Thompson Sampling · Scoring Formula · Pacing Overview · Fair Selection

Chapter 5: The Click

The reader clicks on Takeshi’s ryokan ad. Their browser follows the click URL:

GET /v1/click?pub=yuki-site&url=...&cid=ryokan-001&tok=a8f3...&rid=01HX...

This single click sets off a cascade of learning across the system.

Click Validation

The TrackRoutes handler validates the click:

  1. HMAC verification: The tok parameter is checked against a signature computed from the publisher’s secret, the URL parameters, and the request ID. If anyone tampered with the URL — changed the creative ID, the campaign, or the CPM — the signature won’t match. 403 Forbidden.

  2. Freshness check: The b parameter encodes a time bucket (1-minute granularity). If the click URL is more than 3 minutes old, it’s rejected. This limits the replay window.

  3. Replay guard: The canonical URL (including the unique request ID) is checked against a sharded bloom filter. If this exact click was already recorded, it’s a duplicate. 409 Conflict.

All three pass. The click is legitimate. 204 No Content — acknowledged.

Three Systems Learn from This Click

The LearningEventLog routes the click event to three different parts of the system, each learning at a different timescale.

1. TaxonomyRankerEntity — Category Ranking (Days)

The click event reaches the ranker for Travel on Yuki’s site:

Before: Beta(12, 88)  — mean CTR: 12%
After:  Beta(13, 88)  — mean CTR: 12.9%

One more click in the numerator. The Travel category’s score on Yuki’s site ticks up slightly. Over weeks, this shapes which categories get prioritized in auctions for this site. A category that consistently gets clicks rises; one that doesn’t fades.

The ranker uses a 7-day half-life decay — old impressions and clicks gradually lose weight. This means the ranking adapts to seasonal changes: Travel might dominate in autumn when people plan trips, while Food & Drink rises in December when people search for holiday dining.

2. AdServer — Creative Thompson Sampling (Minutes)

The click reaches Takeshi’s creative stats on the AdServer:

Before: Beta(1, 1)   — uniform, no data
After:  Beta(2, 1)   — heavily skewed toward high CTR

This is one impression, one click — a 100% click rate. Obviously that won’t last, but it gives Takeshi’s creative a strong initial signal. The Beta distribution is now Beta(2, 1), which samples high most of the time. For the next few impressions, this creative will be favored by Thompson Sampling.

After 20 more impressions and 1 more click, it’ll be Beta(3, 19) — about 14% CTR. Still good, but more realistic. The distribution is narrowing toward the truth.

The stats use a 60-minute rolling window with 1-minute buckets. This creative’s strong early performance will influence serving decisions for the next hour, then the data starts aging out and the system stays responsive to changes.

3. Dashboard Projection (Seconds)

The click is written to the tracking journal — a buffered Pekko Stream that batches events and writes them to PostgreSQL. Within a few seconds, Takeshi’s advertiser dashboard updates:

Impressions: 1  |  Clicks: 1  |  CTR: 100%  |  Spend: $0.005

Obviously these numbers will normalize as more data comes in. But Takeshi can see that his campaign is live and getting engagement.

The Compound Effect

This is one click. But notice what it touched:

SystemWhat it learnedTimescaleEffect
Category RankerTravel works on this siteDays-weeksTravel ads get more auction weight
Creative StatsThis creative gets clicksMinutes-hoursThompson Sampling favors it; quality-adjusted clearing also lowers what Takeshi pays
DashboardCampaign is performingSecondsAdvertiser sees results

Each system learns at its own pace. Thompson Sampling reacts within minutes — the next reader might see a different ad mix because of this click. The category ranker reacts over weeks — Travel’s weight on Yuki’s site gradually increases.

There’s no campaign-side bid optimizer adjusting Takeshi’s price after the click. The auction handles that automatically: a higher sampled CTR means a lower clearing CPM under quality-adjusted second-price pricing. Takeshi gets cheaper impressions for making creatives readers actually click, with no agent in the loop.

Meanwhile, the Other Creative

While Takeshi’s ryokan ad got a click, the hiking gear ad has had 3 impressions and zero clicks. Its distribution is now Beta(1, 4) — mean CTR about 20%, but the distribution is starting to lean toward lower values.

Thompson Sampling will still occasionally select it — the Beta(1, 4) distribution can sample anywhere from 0 to 0.8, just with lower probability of sampling high. If it gets a click on its next impression, it recovers immediately: Beta(2, 4) is a much more competitive distribution.

If it continues to get no clicks, it fades out naturally. By the time it has 50 impressions and 0 clicks — Beta(1, 51) — its samples will almost always be near zero. It effectively stops being shown, without anyone making a decision to stop it.

This is the beauty of Thompson Sampling: bad creatives don’t need to be manually paused. They extinguish themselves.


Technical deep dives: Beta-Bernoulli Model · Scoring Formula · Learning Mechanisms

Chapter 6: A Day in the Life

Let’s follow Takeshi’s ryokan campaign through its first full day.

Morning: The Grace Period (8:00-8:02am)

Yuki’s site gets its first traffic of the day. The PI pacing controller has just started a new day — it doesn’t know the request rate yet. For the first 10 seconds (or 10 requests, whichever is later), the controller is in grace period: it throttles at 99%, serving almost nothing.

Why? Because the controller needs to measure the traffic rate before it can regulate it. Serving aggressively without knowing the rate could blow the budget in the first few minutes. Better to be cautious for 10 seconds and get a baseline.

After 10 requests, the TrafficObserver has computed an exponentially-weighted moving average of the request rate: about 2 requests per second at this hour. The PI controller calculates a base throttle:

ideal_serve_rate = budget_remaining / time_remaining / avg_cpm × 1000
                 = $20 / 86400s / $5 × 1000 = 0.046 serves/second

throttle = 1 - (ideal_serve_rate / observed_rate) = 1 - (0.046 / 2.0) = 0.977

That’s aggressive throttling — skip 97.7% of requests. But that’s correct: $20 of budget at $5 CPM is only 4,000 impressions across the entire day. At 2 requests per second, that’s about 2,000 seconds of full serving — but the day is 86,400 seconds long. The campaign needs to spread thin.

Grace period ends. Normal serving begins.

Mid-Morning: Thompson Sampling Converges (8:00-11:00am)

Three hours in. Takeshi’s ryokan has been shown 15 times, getting 2 clicks. The hiking gear ad has been shown 12 times, zero clicks.

The Thompson Sampling distributions have diverged:

Ryokan:     Beta(3, 14)  — mean ~18%, samples usually between 5-35%
Hiking Gear: Beta(1, 13) — mean ~7%, samples usually between 0-20%

The ryokan ad is winning most selections now. Not every time — Thompson Sampling still occasionally picks the hiking gear ad (when its sample happens to beat the ryokan’s). But the ratio has shifted from 50/50 to roughly 70/30.

If the hiking gear ad gets a click in its next few impressions, the ratio will tighten. If it doesn’t, it’ll fade further. No one needs to decide when to stop testing. The system self-regulates.

Noon: A Reader Folds the Corner (12:00pm)

Four hours in. A reader on her lunch break opens an article on Yuki’s blog about hot springs in the Hakone region. Takeshi’s ryokan ad is in the sidebar — the collapsed magazine creative showing the cover photo of his garden bath. She taps it. The overlay expands: the cover, then a story page about the rooms, another about the meals, a final page with a “Reserve” button. She isn’t booking a trip today, but she might in autumn. Before collapsing the ad, she folds the corner.

A POST /v1/dogear-event fires from her browser, carrying a FoldToken the serve response handed her earlier:

{
  "token": "<HMAC-signed payload: pub|url|slot|cid|ver|bucket|camp|adv|nonce>",
  "slot":  "sidebar-1",
  "cid":   "ryokan-magazine-001"
}

The fold endpoint verifies the HMAC, checks the time bucket is fresh, and accepts. Three things happen, all engagement-only — no billing, no auction state change:

  • Pin stored in the reader’s browser. The dogear-storage IndexedDB row in her browser remembers (advertiserId, creativeId) so the next page load on Yuki’s site that’s eligible for Takeshi will surface this exact creative.
  • logFold writes a tracking event. The dashboard projection ticks the campaign’s fold counter — a reader-engagement signal Takeshi can see on his dashboard.
  • No CPM clearing, no budget reservation. Folds are free. The fold isn’t a billable event; the original impression already cleared.

There’s no RL agent to “observe” this. The auction doesn’t change behavior. What changes is that this reader is now linked, by her own choice, to Takeshi’s campaign. Tomorrow, when she lands on a page where Takeshi is eligible, the bookmarked creative is the one served — bypassing the auction reservation and the pacing throttle. The pin is her vote, and the system honors it.

It’s the first thing in this story that wouldn’t happen on a traditional ad exchange. Readers don’t get to bookmark ads anywhere else.

Afternoon: Pacing Adjusts (2:00-5:00pm)

Traffic on Yuki’s site shifts. The morning peak (8-11am) is over. Afternoon traffic is lighter — about 0.8 requests per second instead of 2. The PI controller detects the drop through its rate tracker and adjusts:

Previous throttle: 0.977 (skip 97.7%)
New throttle:      0.943 (skip 94.3%)

Less throttling because the traffic rate dropped. The campaign serves a larger fraction of the smaller number of requests, maintaining a steady spend rate.

But there’s more: the traffic shape tracker has been learning Yuki’s hourly traffic pattern. After a few days (not the first day — the tracker needs data), it will know:

Hour 8:  12% of daily traffic
Hour 9:  11%
Hour 10: 10%
...
Hour 14: 4%
Hour 15: 3%
...
Hour 20: 8%  (evening peak)

Instead of assuming linear time = linear spend, the pacing target will follow this shape. “Spend 12% of budget during the 8am hour, 3% during the 3pm hour.” This prevents the common failure mode of conventional pacing: spending too much during peaks and running dry, or throttling too hard during peaks and having leftover budget at night.

Evening: A Re-Auction (7:00pm)

A re-auction fires for Yuki’s site. What’s changed since 2am?

  • JR Rail Pass campaign ran out of budget at 4pm. Its $8 CPM bid was the highest, but its CTR was mediocre — quality-adjusted clearing kept its eCPM lower than the bid, but the volume still drained the daily budget by mid-afternoon. The pacing controller has been throttling its serves for the last hour.
  • A new advertiser appeared: a Kyoto pottery workshop, targeting East Asian Culture, $3 CPM.

The auction re-runs with the updated participants:

Slot 1 (banner):  Takeshi's Ryokan  — $5.00 CPM (honest bid)
Slot 2 (sidebar): Hiking Gear Co    — $4.00 CPM
Slot 3 (sidebar): Pottery Workshop  — $3.00 CPM (new!)

JR Rail Pass is gone — budget exhausted. But its creatives stay in the ServeIndex with a refreshed TTL (they’ll be there when budget resets tomorrow). Takeshi’s ryokan, which was the second-highest bidder this morning, is now the top bidder. Note Takeshi’s bid hasn’t changed — the auction extracts the right clearing price from the runner-up’s score, so there’s nothing for Takeshi to “tune.”

The re-auction takes about 3 seconds. The new candidates propagate to all nodes within 2 seconds of gossip. The next reader sees the updated lineup.

End of Day: Reset

At midnight (or the configured day boundary), the day resets.

CampaignEntity: Budget resets to $20. Spend counter goes to zero. The pacing buckets reset, the spend Bloom filter rolls. There’s no agent to “reset” — the campaign always bids its honest $5 CPM, and the auction’s quality-adjusted second-price clearing means Takeshi’s effective price keeps drifting down as his CTR builds.

TrafficShapeTracker: Today’s hourly traffic volumes are blended into the stored profile with dayAlpha = 0.2. After 5 days, the profile is a smoothed average of observed traffic patterns.

Thompson Sampling stats: The 60-minute rolling window means the last hour of creative stats carries into the new day. Older stats have already aged out. The system doesn’t need an explicit reset.

Budget event published: A CampaignBudgetReset event tells the AuctioneerEntity that Takeshi’s campaign has fresh budget. A debounced re-auction fires within 1 second, and the ryokan ad is back in the candidate pool at full strength.

Day 1 is done. The system served relevant ads, learned which creatives work, paced budgets smoothly, and adapted to traffic patterns — all automatically.

Tomorrow it will be slightly smarter.


Technical deep dives: PI Control Loop · Traffic Shape Learning · Grace Periods · Re-Auction

Chapter 7: A Week Later

Seven days have passed. Let’s see what the system has learned.

Takeshi’s Effective CPM Has Drifted Down

Takeshi’s max CPM is still $5 — he hasn’t touched it. He’s never going to touch it; there’s nothing to touch. But his effective CPM, what he actually pays per won impression, has been dropping all week.

The reason is the auction itself. Quality-adjusted second-price clearing computes the winner’s payment as the minimum CPM that would still beat the runner-up given its sampled CTR:

clearingCPM = (bestLoserScore / sampledCTR_winner) ^ (1/α)

As Takeshi’s Beta(15, 299) distribution narrowed around 4.8% CTR, his sampledCTR_winner settled higher than most competitors. Higher CTR → lower clearing CPM. By day 7, Takeshi’s eCPM is around $3.40 — well under his $5 max. He’s getting a 32% quality discount, automatic, with no agent in the loop.

The JR Rail Pass campaign tells the opposite story. Its $8 max bid would dominate a pure first-price auction, but with a 3.1% CTR competing against Takeshi’s 4.8%, its effective CPM stays close to its bid — the auction has nothing to discount. It still wins many slots (its raw CPM is high enough to overcome the CTR gap), but it pays for them.

This is what replaces a campaign-side bid optimizer. The auction itself extracts honest bids and rewards quality. There’s nothing for an RL agent to learn here — bid shading would just lose impressions, and the right price for any given impression depends on the runner-up, which the campaign can’t observe in advance anyway.

A Few Readers Have Dog-Eared the Ad

The dashboard projection has been counting fold events:

Takeshi's ryokan-magazine-001:
  Folds this week:  9
  Pin re-encounters: 4 (so far)

Nine readers have folded the corner of Takeshi’s creative this week. Four of them have already returned to a page where Takeshi was eligible — and instead of the auction running, the ad they bookmarked was the one served. Those re-encounters bypass CPM clearing (free), bypass pacing throttle (a bookmark is a reader’s choice, not a billable serve), and don’t count against the daily budget.

For Takeshi, this is a quietly powerful effect. Nine reader-driven bookmarks in a week is more loyalty than a typical retargeting campaign produces, and he didn’t pay for the re-encounters. The pins live in the readers’ own browsers; the server doesn’t know who they are, only that someone with that browser folded that creative.

In a few months, when one of those readers actually plans an autumn trip and lands on a Hakone article, Takeshi’s ryokan will be the ad they see. The bookmark is doing the work that retargeting tries to do, without the surveillance.

Thompson Sampling Has Converged

After hundreds of impressions, the creative stats tell a clear story:

CreativeImpressionsClicksDistributionMean CTR
Takeshi’s Ryokan31214Beta(15, 299)4.8%
JR Rail Pass2878Beta(9, 280)3.1%
Hiking Gear Co891Beta(2, 89)2.2%
Pottery Workshop453Beta(4, 43)8.5%

Takeshi’s ryokan gets the most impressions — it has a proven CTR and a decent CPM. JR Rail Pass has a higher CPM but lower CTR; the scoring formula sampledCTR × CPM^α (publisher α=0.5) keeps them competitive but Takeshi’s CTR advantage matters — and translates directly into a lower clearing price for him.

The pottery workshop is interesting. It has fewer impressions (it started later in the week) but its CTR is the highest — 8.5%. Its Beta(4, 43) distribution is still fairly wide, though. Thompson Sampling is giving it more exploration to confirm whether this high CTR is real or noise.

The hiking gear ad has mostly faded out. Beta(2, 89) samples near zero most of the time. It gets about 5% of impressions — just enough exploration to detect if something changes (new creative, seasonal shift). If the advertiser uploaded a better creative, the system would detect the improvement within hours.

Nobody made any of these allocation decisions. No one paused the hiking gear campaign or boosted the pottery workshop. The system found the right distribution through pure learning.

The Category Ranker Has Opinions

The TaxonomyRankerEntity for Yuki’s site has accumulated a week of data:

Travel:           Beta(45, 355)  — 11.3% CTR, tight distribution
Hiking/Camping:   Beta(8, 192)   — 4.0% CTR, fairly confident
East Asian Culture: Beta(5, 45)  — 10.0% CTR, still exploring
Food & Drink:     Beta(2, 28)    — 6.7% CTR, early data

Travel dominates — it gets the highest weight in most auctions. But East Asian Culture is a surprise performer. The pottery workshop (East Asian Culture category) is driving this. The ranker is giving East Asian Culture more auction weight, which means more bidding opportunities for advertisers in that category.

This creates a virtuous cycle: good category performance → more auction weight → more candidates → more data → better Thompson Sampling → better ads → higher CTR → higher category performance.

Traffic Shapes Are Calibrated

The TrafficShapeTracker now has 7 days of hourly data for Yuki’s site, blended with dayAlpha = 0.2:

Weekday profile:
  Hour  0-6:   1-2% each  (late night, minimal traffic)
  Hour  7:     4%          (morning commute)
  Hour  8-10: 10-12% each (peak reading time)
  Hour 11-13:  6-7% each  (lunch)
  Hour 14-17:  3-4% each  (afternoon lull)
  Hour 18-20:  7-9% each  (evening reading)
  Hour 21-23:  3-4% each  (winding down)

The PI controller uses this shape instead of a linear time fraction. At 10am, it knows 32% of daily traffic has typically passed (not 42% if you assumed linear). This means the pacing target at 10am is “have spent about 32% of budget” — not 42%. The result: budgets stretch correctly across the day’s actual traffic pattern, not an imaginary uniform distribution.

Pacing Has Self-Tuned

The PI controller has been adjusting itself:

  • Overpace multiplier: Started at 2.0×. After detecting that JR Rail Pass occasionally overpaced by 20% in the morning (its $8 CPM combined with peak traffic kept burning through budget faster than the linear target), it increased to 2.8×. This means the controller responds more aggressively to overspending — a correction learned from experience.

  • Spend ratio smoothing: The adaptive EMA alpha settled at 0.25. The traffic on Yuki’s site is moderately volatile (it spikes when she publishes a new article and posts to social media). The controller learned to smooth more than the default to avoid overreacting to these spikes.

What Yuki Sees

Yuki checks her publisher dashboard:

This Week:
  Impressions served:  1,847
  Revenue:             $9.24
  Active advertisers:  4
  Top category:        Travel (58% of impressions)
  Approval queue:      2 new creatives pending review

The revenue isn’t life-changing — it’s a small blog. But the ads are relevant, the site is fast, and her readers haven’t complained. She approves the two pending creatives (a Kyoto walking tour and a Japanese language school) and goes back to writing her next article.

What Takeshi Sees

Takeshi checks his advertiser dashboard:

This Week:
  Impressions:  312
  Clicks:       14
  CTR:          4.5%
  Spend:        $1.56
  Avg CPC:      $0.11

Fourteen people clicked through to his ryokan’s booking page from a travel blog — exactly the kind of reader he wanted to reach. His cost per click is $0.11. He didn’t have to manage bids, adjust targeting, or learn a DSP interface. He uploaded a photo, set a budget, and the system did the rest.

He increases his daily budget to $30.

The Floor Has Nudged Up

Yuki hasn’t touched her site’s floor CPM all week — but it’s not the same number it started at.

The publisher-side floor RL agent has been watching the bid spread on Yuki’s site. Most of the week, four campaigns have been competing for her slots at $3, $4, $5, and $8 — a wide enough spread (>1.5×) that floor adjustments can plausibly move outcomes without collapsing fill. The agent nudged the floor from $0.50 to $0.80 over five days. Fill stayed healthy; clearing prices on cold-start serves came in higher; Yuki’s revenue ticked up about 6% on top of what the auction itself was earning her.

If the spread had been narrow — every bidder offering the same CPM — the agent would have stayed put. Moving the floor in a homogeneous market just shrinks fill without raising prices. The agent is gated by exactly this signal.

This is the only RL agent in the system, and it runs on the publisher’s side. Advertisers see honest second-price clearing regardless of where the floor sits.

The System Keeps Learning

Day 8 begins. The system has found its rhythm: a travel blog with relevant ads, a local ryokan reaching interested travelers, creative performance continuously sharpened by Thompson Sampling, budgets paced smoothly, the publisher’s floor tuned to the actual bid spread, and a small but growing set of readers who have explicitly bookmarked the ads they want to come back to.

It isn’t done learning. New advertisers will join. Yuki’s traffic shape will shift with the seasons. Some readers will fold ads; others will block them; the dashboard will reflect both. The pottery workshop’s Beta(4, 43) is still wide enough that next week could swing either way.

But notice what’s not happening: nobody is tuning a bid multiplier. Takeshi isn’t checking his “bid strategy.” There’s no auto-bidder cycling through state-action pairs in a Q-table. The auction itself extracts honest bids, the readers vote with their dog-ears, and the publisher’s floor agent handles the one piece of price-side learning that’s actually informative.

This is what advertising looks like when you start with the content, the format, and the reader — instead of the user profile.


Technical deep dives: Scoring Formula · Traffic Shape Learning · Key Innovations

Technical Introduction

This chapter provides a concise technical overview of Promovolve’s architecture and algorithms. For the motivation behind these design choices, see Why Promovolve?.

The Six Key Mechanisms

1. Periodic Batch Auction

Auctions happen when content is published or updated (scheduled crawl + 5-minute re-auctions), not on every page load. An LLM classifies page content into IAB categories, TaxonomyRankerEntity ranks categories by site-specific performance, and CategoryBidderEntity collects bids from eligible campaigns. Results are cached in DData.

2. Multi-Candidate Caching

Instead of a single auction winner, multiple candidates per ad slot are shortlisted with per-campaign diversity guarantees and stored in the ServeIndex (replicated in-memory via DData). This enables exploration at serve time without re-running the auction.

3. Thompson Sampling at Serve Time

When a user loads a page, Thompson Sampling selects among cached candidates:

score = sampledCTR × CPM^α

CTR is sampled from a Beta-Bernoulli posterior using time-bucketed statistics (1-minute granularity, 60-minute rolling window). The exponent α (bidWeight) is publisher-configurable — α=0.3 (Discovery) lets quality dominate so small advertisers compete; α=0.5 (Balanced) is sqrt(CPM), the default; α=0.7 (Revenue) tilts back toward higher bids. CTR is the multiplicative factor: a creative that users actually click beats one that merely bids high.

4. Quality-Adjusted Second-Price Pricing

The exploiting winner doesn’t pay its own bid. It pays the minimum CPM that would have kept it ahead of the next-best candidate given its sampled CTR — a quality-adjusted second price. There’s no upside to bid shading, so Promovolve runs no campaign-side bid optimizer. Pinned re-encounters (see §6) bypass pricing entirely; cold-start serves clear at the publisher’s floor.

5. Self-Tuning PI Pacing

A PI controller with adaptive gains, traffic shape learning (separate weekday/weekend 24-hour profiles), oscillation detection, and leaky integrator anti-windup smooths budget delivery. It learns that traffic peaks at 10am and dips at 3pm, and adjusts automatically. A separate publisher-side RL agent tunes the floor CPM upward when bid spread suggests the market can bear it, and downward when fill suffers.

6. The Magazine Format and the Dog-Ear

A Promovolve creative is an expandable, multi-page magazine spread, not a static rectangle. The collapsed view sits in the publisher’s slot; tapped, it opens into a full-screen overlay the reader can swipe through. Readers can fold the corner of a creative they want to remember — a literal dog-ear — and the next time they land on a page where that advertiser is eligible, the bookmarked creative is the one they see. The pin lives in the reader’s browser (IndexedDB), signed by a stateless FoldToken; the server never stores who folded what. Pinned slots bypass auction reservation and pacing throttle, treating the pin as a free re-encounter rather than a billable serve.

The Result

Sub-millisecond ad serving. Continuous learning at four layers (per-request Thompson Sampling, per-auction category ranking, daily traffic shapes, continuous PI tuning) plus publisher-side floor RL. Reader-controlled bookmarks instead of advertiser-controlled retargeting. Graceful degradation when budgets exhaust. No user tracking. Publisher approval over every creative.

  • Architecture — Pekko cluster topology, entity hierarchy, data flow
  • Auction — The five phases of the periodic batch auction
  • Serving — Thompson Sampling, cold start, fair selection, pin-honoring
  • Pacing — PI control, self-tuning, traffic shape learning
  • Distributed State — ServeIndex replication and consistency
  • Comparison — Point-by-point mapping against traditional ad tech

Each chapter is self-contained. All formulas, thresholds, and constants come from the Scala source code.

How Ad Tech Works (and Where Promovolve Diverges)

If you’ve never worked in ad tech, the alphabet soup of SSPs, DSPs, DMPs, and RTB can be impenetrable. This chapter explains the traditional programmatic advertising stack from the ground up, then shows how Promovolve makes different choices at each layer.

The Simplest Version: A Magazine

Before the internet, advertising was straightforward.

A magazine about cooking has readers who care about cooking. A kitchen equipment company wants to reach people who care about cooking. The magazine’s ad sales team calls the kitchen equipment company and says: “We’ll put your ad on page 47, next to our article about French sauces, for $5,000.” They shake hands. Done.

Three participants. One transaction. Everyone understands what they’re getting:

  • The advertiser gets their ad in front of relevant readers
  • The publisher gets paid for their audience’s attention
  • The reader sees an ad that relates to what they’re already reading

This is direct sales. It works beautifully when the publisher and advertiser know each other. It doesn’t scale to millions of websites and millions of advertisers who have never met.

The Internet Problem: Too Many Strangers

A small travel blog in Kyoto has readers who love Japanese travel. A ryokan in Hakone would love to reach those readers. But the blog owner doesn’t know the ryokan exists, and the ryokan owner doesn’t know the blog exists. Neither has a sales team.

Multiply this by millions of websites and millions of businesses. The matching problem — connecting the right ad to the right page — is too large for humans to solve one deal at a time.

The ad tech industry’s answer was to automate the matching with machines. But the system they built optimized for a particular set of goals, and those goals don’t serve everyone.

The Traditional Stack: Who Does What

The Publisher’s Side: SSP (Supply-Side Platform)

The publisher (our Kyoto travel blog) signs up with an SSP — companies like Google Ad Manager, Magnite, or PubMatic. The SSP provides a piece of JavaScript that goes on every page. When a reader loads the page, this script calls the SSP: “I have an ad slot, 300x250 pixels, on this URL. Who wants it?”

The SSP’s job is to get the highest price for this impression. It does this by offering it to exchanges and DSPs.

The Advertiser’s Side: DSP (Demand-Side Platform)

The ryokan in Hakone signs up with a DSP — companies like The Trade Desk, DV360, or Amazon DSP. The ryokan uploads its ad creative, sets a budget ($50/day), defines a target audience (“people interested in travel to Japan”), and sets a maximum bid ($3 CPM).

The DSP’s job is to find the right impressions to buy and bid the right price. It does this by listening for bid requests from exchanges and deciding, in real time, whether this particular impression is worth bidding on.

The Middle: The Ad Exchange

The exchange (Google AdX, OpenX, etc.) sits between SSPs and DSPs. When the SSP says “I have an impression,” the exchange broadcasts it to every connected DSP: “Who wants this? You have 100 milliseconds to decide.”

Each DSP evaluates the impression:

  • Does this page match the advertiser’s targeting criteria?
  • How much is this user worth, based on their profile?
  • What’s the right bid price?

The DSPs that want it send back bids. The exchange picks the highest bidder. The winning DSP’s ad is served.

The Invisible Layer: DMP (Data Management Platform)

How does the DSP know “how much this user is worth”? It consults user profiles — built from cookies, device IDs, and cross-site tracking data aggregated by DMPs. These profiles say things like: “This user visited car dealership websites last week” or “This user is 25-34, lives in Tokyo, and recently searched for flights.”

This is where the magazine model breaks down completely. The DSP isn’t asking “what is this person reading?” It’s asking “who is this person?” The travel blog’s content about Kyoto temples is irrelevant. The ad is targeting the user, not the page.

What Goes Wrong

This system works — in the narrow sense that money flows and ads get served. But it has structural problems.

The publisher becomes a commodity

In this model, the publisher’s content doesn’t matter. What matters is the user sitting on the page and the cookie in their browser. A thoughtfully researched article about Kyoto architecture and a hastily assembled listicle about “10 things in Japan” are, to the exchange, interchangeable: they carry the same user with the same cookie.

Publishers who invest in quality content get paid the same as those who don’t. The incentive is to maximize page views, not quality — because the ad system doesn’t value quality.

The user experience degrades

Each ad slot triggers a cascade of network requests. The SSP calls the exchange. The exchange calls multiple DSPs. Each DSP calls its DMP. The responses flow back. On a page with five ad slots, this happens five times in parallel. Header bidding (the publisher’s attempt to get better prices) adds another round. The result: ad-related requests often take longer than the page content itself.

And the ads themselves: because they target users, not content, they feel random and intrusive. You read about temple architecture, you see an ad for the shoes you browsed last night. The disconnect is jarring.

Small players can’t participate

This infrastructure has minimum viable scale. SSPs have traffic minimums. DSPs require campaign management expertise. The exchange’s auction mechanics favor large bidders with sophisticated real-time bidding algorithms. The ryokan in Hakone and the Kyoto travel blog — the exact pair that should be connected — can’t afford to play.

How Promovolve Rethinks Each Layer

Promovolve doesn’t try to improve the traditional stack. It replaces the fundamental assumptions.

Instead of targeting users → target content

The entire SSP/DSP/DMP chain exists because the system decided to target users. Remove that decision, and most of the machinery becomes unnecessary.

Promovolve classifies page content using an LLM into IAB Content Taxonomy 2.1 categories: “This article is about Destinations, Outdoor Recreation, Cultural Tourism.” Meanwhile, an advertiser says: “My product is Travel” (IAB Ad Product Taxonomy 2.0). The system automatically derives which content categories match that product using the official IAB mapping — no manual configuration. The match happens between content and product, not content and user. No user profile needed. No DMP. No cookies.

This is the magazine model, automated. The technology that makes it work at scale — cheap, accurate LLM classification — didn’t exist five years ago.

Instead of real-time auctions → periodic batch auctions

Traditional auctions run on every page load because they need to evaluate the user in real time. Promovolve doesn’t need to — the content doesn’t change between page loads.

The auction runs when content is crawled (scheduled + re-auctions every 5 minutes). Multiple candidates per slot are cached in a replicated in-memory store (Pekko DData). When a user loads the page, the ad is already there. No network round-trip, no exchange, no 100ms wait.

Serve latency drops from 50-200ms to under 1ms.

Instead of a single winner → multiple candidates with exploration

A traditional auction picks one winner: the highest bidder. That’s it. If a new advertiser with a potentially better creative enters, they lose to the established high bidder and never get a chance to prove themselves.

Promovolve caches multiple candidates and uses Thompson Sampling to choose at serve time. A new creative with no track record gets explored — shown to some users to learn its click-through rate. If it performs well, it earns more impressions. If not, it fades out naturally. No A/B test configuration needed. The system learns automatically.

Instead of DSP bid algorithms → quality-adjusted second-price clearing

In the traditional stack, each DSP runs sophisticated bid optimization across all its campaigns. The ryokan in Hakone doesn’t have a DSP; it can’t participate.

Promovolve replaces the bid optimizer with the auction mechanism itself. The ryokan sets a maximum CPM and a daily budget. At serve time, candidates are scored as sampledCTR × CPM^α and the winner pays the minimum CPM that still beats the runner-up given its CTR — a quality-adjusted second-price clearing. There’s no upside to bid shading and nothing to optimize against, so Promovolve runs no campaign-side RL agent at all. A creative that earns clicks pays less than one that merely outbid; honest bids are the dominant strategy.

No DSP integration. No bid management expertise. The auction handles it.

Instead of per-impression database writes → buffered spend tracking

Traditional systems write to a database on every impression to track spend. At scale, this becomes a bottleneck.

Promovolve buffers spend events in the campaign actor (flush every 500ms or 20 events), deduplicates with a Bloom filter, and persists atomically. This reduces database writes dramatically while maintaining correctness through idempotency guarantees.

Instead of intermediary fees → direct connection

In the traditional stack, money passes through multiple intermediaries: DSP, exchange, SSP. Each takes a percentage.

Promovolve connects advertisers and publishers directly. The advertiser’s budget goes to the publisher, minus the platform’s single fee. There’s no exchange, no DSP, no DMP taking a cut.

What Promovolve Gives Up

These trade-offs are real and worth understanding:

No cross-publisher reach. A DSP campaign can target users across thousands of websites simultaneously. Promovolve works per-publisher (or per-publisher-network). An advertiser who wants broad reach across unrelated sites needs the traditional stack.

No user-level targeting. If an advertiser specifically wants to reach “women aged 25-34 in Tokyo who recently searched for hotels,” Promovolve can’t help. It can reach “readers of content about hotels in Tokyo,” which may overlap significantly, but it’s a different kind of targeting.

No real-time price discovery on every impression. Traditional exchanges run a fresh competitive auction on every page load and reveal a market-clearing price for that exact moment. Promovolve runs the auction once per crawl (and on a 5-minute re-auction tick), so the clearing price reflects the market over a window, not the millisecond. The auction itself is competitive — quality-adjusted second-price clearing extracts honest bids — but it’s batch, not realtime.

Stale auction results. Traditional RTB reflects the state of the world right now. Promovolve’s cached candidates can be up to 5 minutes old (the re-auction interval). A campaign that paused 2 minutes ago might still be served until the next re-auction.

No user retargeting. The “you looked at shoes, now see shoe ads everywhere” pattern is impossible in Promovolve. For some, this is a feature.

When Promovolve Makes Sense

Promovolve is the right choice when:

  • The publisher’s content is the value proposition, not access to trackable users
  • Advertisers want contextual relevance — their ad next to related content
  • Page performance matters — sub-millisecond serving vs. 200ms ad waterfalls
  • The publisher wants control over what appears on their site (approval workflow)
  • Privacy is a genuine concern, not just a compliance checkbox
  • Participants include small advertisers — local businesses, community announcements — who can’t access the programmatic stack

It’s the wrong choice when:

  • The advertiser needs cross-publisher user retargeting
  • Market-clearing price discovery is important for the business model
  • The publisher’s value is user data, not content quality

The next chapters examine each of these differences in technical detail.

The Magazine Format

Most things called “ad units” are rectangles. Promovolve’s are magazine spreads.

The collapsed view sits in the publisher’s slot like a magazine ad on a page — a single cover frame, often a hero image with a headline. Tapped, it expands into a full-screen overlay the reader can swipe through: cover, story pages, a call-to-action page. Tapped close, it folds back. If the reader wants to remember it, they can dog-ear the corner; the next time the same advertiser is eligible, the bookmarked creative is the one they see.

That whole flow runs as a single Web Component — <expandable-magazine-banner> — embedded in the publisher’s page. The component is the canonical surface; the rest of this section explains what flows through it.

From banner to spread

The reading experience has four distinct states, each with a clean transition:

StateWhat the reader sees
CollapsedCover page rendered into the slot, sized to the slot
ExpandedFull-screen overlay; cover plus interior pages, swipeable
CTA pageFinal page of the spread; tapping fires the CTA event
FoldedCollapsed view with the corner clipped — pin is set

Expansion is opt-in. A reader who isn’t interested taps past the slot and never sees more than the cover; the impression is recorded but no further bytes are loaded. A reader who taps in chooses, in that moment, to spend attention on the ad. The advertiser pays for the cover impression; the engaged time is a bonus, not something the reader can be tricked into.

This is closer to magazine reading than to web advertising. A magazine reader flips past most ads. A few catch their eye and they pause. Promovolve makes that explicit.

Why expandable, not video or popup

Three formats compete for the “more than a banner” slot. Each makes a different bet about reader patience:

  • Auto-play video assumes the reader will tolerate motion in their peripheral vision and will eventually look at it. Many readers don’t, and adopt blockers when forced.
  • Popup / interstitial assumes the reader will tolerate having their reading interrupted. Most don’t, and the experience trains them to dismiss without reading.
  • Expandable magazine assumes the reader has zero patience, and so it does nothing the reader didn’t ask for. The cover is the entire above-the-fold cost; everything else is opt-in.

The third bet is harder for advertisers — it concedes that a reader who isn’t curious gets a single quiet impression and nothing more. But it’s the only one of the three that doesn’t degrade with each new generation of ad-blocker.

Anatomy of a creative

A creative is an ordered list of Page objects plus a top-level BannerConfig. The page schema is permissive — it carries headline, sub, body, caption, optional hero image, optional video background, page-level background color, and a list of layout items the designer positioned by hand or auto-layout generated.

interface Page {
  headline?: string;
  sub?: string;
  body?: string;
  caption?: string;
  img?: string;
  bg?: string;        // page background color
  isCTA?: boolean;
  ctaUrl?: string;
  ctaLabel?: string;
  layout?: LayoutItem[];
  videoBg?: VideoBg;
  // …
}

A few page-level details matter for the format:

  • Cover page is author-chosen. BannerConfig.coverPageIdx selects which page renders in the collapsed slot. Default is page 0, but an author whose strongest hook is page 3 can promote it. The designer’s “★ Cover” toggle drives this directly.
  • Page background is a color, not an image. The overlay derives its surrounding background from the cover page’s bg (using color-mix with luminance flip) so the expanded view feels like the cover spread into the whole screen, not a popup pasted on top.
  • CTA pages are special. A page with isCTA=true becomes clickable. Layout items can opt in individually with ctaTarget=true; if no items are marked, the whole page is the click target as a fallback. Tapping fires a cta-click custom event the bootstrap listens for.

Component shapes (text, image, rect, circle) carry their own positioning, animation targets, and content references. Animation is per-item and per-target: an item can fade, translate, scale, or rotate from a base state to a MotionTarget, with configurable duration, delay, and easing.

The web component contract

The publisher embeds expandable-magazine-banner.js once per page. The bootstrap script then writes a <expandable-magazine-banner> element into each ad slot with the attributes the runtime needs.

The observed attributes are the public contract:

AttributeMeaning
pagesJSON-encoded array of Page objects — the spread itself
configJSON-encoded BannerConfig — font, expand effect, cover index, etc.
width, heightAuthored dimensions; used as the aspect ratio, not as a fixed size (see Fluid Creatives)
collapsed-page-indexDesigner / preview override of coverPageIdx
mode"edit" for designer canvas, otherwise production
imp-urlSigned impression beacon URL — fired when ≥50% of the slot is in viewport
click-urlSigned click beacon URL — fired once per mount on first expansion
landing-urlFallback CTA URL when page.ctaUrl is empty
data-can-fold"false" opts a serve out of the dog-ear corner
data-fold-tokenHMAC-signed FoldToken the bootstrap redeems via /v1/dogear-event when the reader folds

Everything inside the element is rendered into Shadow DOM. The publisher’s CSS can’t leak in; the banner’s CSS can’t leak out. The page-level styles the publisher cares about — slot width, slot aspect ratio — apply through the host element’s width and aspect-ratio properties (the Fluid Creatives chapter covers the responsive sizing pattern).

Lifecycle: impression, click, CTA, fold

Each of the four engagement events is a different commitment from the reader, and each has a different beacon endpoint.

Impression — /v1/imp

Fires when the slot becomes viewable: an IntersectionObserver watches the host element with a 50% visibility threshold, and the impression beacon goes out the first time that threshold is crossed. The _impressionFired flag prevents re-fire when the slot scrolls out and back in.

IntersectionObserver(threshold=0.5) → first crossing → GET imp-url (1×1 pixel)

This is the billable event. CPM clearing happens server-side based on the auction outcome the bootstrap already received in the serve response.

Click — /v1/click

Fires the first time the reader expands the banner — tapping the collapsed slot. “Click” here is the historical name; functionally it’s the expansion signal. The _clickFired flag makes it one-shot per mount: a reader who expands, closes, and re-expands doesn’t get a 409 from the server’s idempotency check.

CTA click — /v1/cta

Fires when the reader taps the call-to-action on the CTA page. The page or marked layout items dispatch a cta-click custom event; the bootstrap listens, fires the CTA beacon, and opens page.ctaUrl (or landing-url as fallback) in a new window.

The three-tier model — impression (viewable) → click (expanded) → CTA (engaged) — gives publishers and advertisers a real funnel instead of the binary “served or not” signal traditional banners produce.

Fold — /v1/dogear-event

Fires when the reader folds the corner of the creative. The bootstrap redeems the data-fold-token it received with the serve, the server verifies the HMAC and freshness, and the creative becomes a pin in the reader’s IndexedDB. Folds are free engagement signals — no CPM clearing, no budget reservation. See The Dog-Ear for the full protocol.

What makes the format work

The magazine format is the surface, not the substance. Three other pieces make the surface viable in production:

  • Fluid creatives — the same creative renders into a 300×250 sidebar and a 375px phone slot, with no separate variants. Aspect-ratio sizing on the host plus container queries inside the Shadow DOM.
  • The LP-to-creative pipeline — small advertisers don’t have to design anything. They enter a landing page URL, and Playwright + Gemini + the in-house designer produce a magazine creative.
  • The dog-ear — the reader’s bookmark, stored in their own browser, that turns the magazine metaphor into actual behavior the reader can act on.

The auction and serving chapters (linked below) describe how creatives reach a slot in the first place. This chapter describes what they look like once they get there.

The Dog-Ear

Pick up a magazine, fold the corner of an interesting page, come back to it later. Promovolve gives readers the same affordance for ads. The bookmark is reader-driven, lives in the reader’s own browser, and survives without any server-side profile of the reader.

This chapter covers the reader-side protocol. The server-side counterpart — how the pin is honored when the reader returns — is in Pin-Honoring at Serve Time.

The mechanic

The reader expands an ad, swipes through its pages, and decides they want to remember it. Tapping the small folded-corner control sets the pin. The corner of the creative visibly clips, both in the expanded view and (on collapse) in the publisher’s slot. The next time the reader lands on a page where that advertiser is eligible, the bookmarked creative is the one served.

A second tap unfolds the corner. The pin is removed.

┌─────────────────────┐         ┌─────────────────────┐
│ unfolded            │   tap   │ folded            ◢ │
│ (cover full)        │ ──────> │ (corner clipped)    │
└─────────────────────┘  fold   └─────────────────────┘
                                          │
                                       reload
                                          ▼
                          ┌─────────────────────┐
                          │ folded; same        │
                          │ creative re-served  │
                          └─────────────────────┘

The folded corner is rendered with a CSS clip-path polygon on the banner, and a separate <div> styled as the flap behind it (6cqmin square, drop-shadowed). Same Shadow DOM machinery as the rest of the magazine format.

Why pin storage lives in the reader’s browser

Traditional retargeting works by writing a cookie or device-graph entry on the advertiser’s behalf, then matching that identifier on every subsequent ad request across the web. The reader’s interest is captured by a third party, in a system the reader can’t inspect.

Promovolve inverts that. The pin lives in the reader’s IndexedDB, under the publisher’s origin, scoped to a pins store keyed by slotId:

{ slotId, creativeId, page, foldedAt, expiresAt }

No personal identifier. No cross-origin storage. No sync. The server learns “someone with that browser folded that creative on that slot,” and only when the bootstrap chooses to surface the pin in a serve request. The reader can clear it by clearing site data — same gesture as removing any browser bookmark.

The IDB schema lives in platform/banner-bootstrap/src/dogear-storage.ts. The TTL is 7 days by default; the server can suggest a longer expiry (e.g., the campaign’s endAt) and the client takes the minimum, capped at 90 days hard so abandoned rows don’t pile up indefinitely.

The FoldToken protocol

The fold has to be authenticated against a real serve. Otherwise anyone could POST /v1/dogear-event with arbitrary (slotId, creativeId) and pin a creative they never saw. The FoldToken is the credential.

When the server picks a winner for a slot and the campaign opted into dog-ear, it mints a token and ships it to the client as data-fold-token on the banner element:

<base64url(payload)>.<base64url(hmac)>

payload  = pub | url | slot | cid | ver | bucket | camp | adv | nonce
hmac     = HMAC-SHA256(canonical || camp | adv | nonce, publisher_secret)

The token is stateless. The server doesn’t store it; it just signs it. When the client redeems the token by POSTing to /v1/dogear-event, the server reverses the steps:

  1. Split on .; base64-decode the payload; parse fields.
  2. Verify the slot and creative match what the client claims.
  3. Recompute the HMAC with the publisher’s secret; compare in constant time.
  4. Check freshness: bucket within 30 minutes of now.

If any step fails, the reason is one of bad_format | bad_payload | slot_mismatch | creative_mismatch | bad_signature | stale and the server returns 403 Forbidden. The full implementation is modules/core/src/main/scala/promovolve/common/FoldToken.scala.

A few design choices worth pointing out:

  • 30-minute freshness window, vs the 3-minute window for /v1/imp and /v1/click. A reader expanding a creative might browse the pages, decide, fold — that takes longer than the impression beacon is willing to wait. 30 minutes is the upper bound on “the reader is still on this serve.”
  • Camp/adv ride inside the signed payload. The fold endpoint records the engagement against the right campaign without a serve-time lookup.
  • Stateless is deliberate. There’s no fold-token table to maintain, no expiration sweep, no replay-protection cache (idempotency lives downstream — see below). HMAC + freshness is enough.

POST /v1/dogear-event

The endpoint takes a JSON body for both fold and unfold:

{
  "pub":         "yuki-site",
  "url":         "https://yukiblog.jp/autumn-hikes",
  "creativeId":  "ryokan-magazine-001",
  "slotId":      "sidebar-1",
  "event":       "fold",
  "foldToken":   "eyJ…<payload>…fA.k7Lz…<hmac>…",
  "page":        2
}

event is "fold" or "unfold". foldToken is required for fold, ignored for unfold. page is the page index the reader was on when they folded — saved into IDB so the re-encounter opens at the same page.

Fold path:

  1. Verify the token (HMAC + freshness + slot/cid match).
  2. Compute requestId = 16-char hex hash of the full token. Same hash function the spend Bloom filter uses, so the journal idempotency layer dedups consistent replays.
  3. Write a TrackEvent to the tracking journal via EventLog.logFold. The dashboard projection counts folds.
  4. Return 204 No Content.

Unfold path:

  1. No token — the reader can always remove their own pin.
  2. Write a telemetry-only TrackEvent via EventLog.logUnfold.
  3. Return 204.

The unfold journal entry exists for the pin retention metric the dashboard projection computes: (folds − unfolds) / folds. Advertisers see how often readers actually come back versus folded then changed their minds.

Server-side: engagement signal, not billing

Folds are free. There is no per-fold CPM, no per-fold budget, no separate fold spend ledger:

// LearningEventLog.logFold (excerpt)
// Folds are an engagement signal, not a billing event.

The server writes the journal entry (so the dashboard can show fold counts and pin retention rate), but no spend is reserved, no auction price is charged. The original impression that produced the fold token already cleared at the quality-adjusted second price; the fold itself is a free signal layered on top.

This is a pricing decision, not just a billing accident. Charging per fold would punish advertisers whose creatives readers want to remember — exactly the wrong incentive.

The re-encounter

The reader visits another page (or reloads the same one). The bootstrap runs:

  1. Read IDB. Open the promovolve-dogear database, scan the pins store, drop any rows past expiresAt.
  2. Submit pins with the batch request. For every slot on the new page, look up the IDB row by slotId. Slot-on-page pins go into req.pins[] as (slotId, creativeId). Slot-not-on-this-page pins also go into the request — the server uses them for the site-wide pin exclusion.
  3. Server picks the winner. Pin-Honoring at Serve Time describes the path: pinned slots bypass the auction, clear at zero, emit DogearOutcome(honored=true).
  4. Bootstrap renders. When the response carries dogear.honored=true, the bootstrap renders the bookmarked creative, sets data-pinned-page to the page index from IDB so the banner opens at the same page the reader folded, and adds ?dogeared=1 to the impression URL so the dashboard can split dogeared from organic impressions.

A re-encountered creative still fires its impression beacon (the publisher’s slot was filled, regardless of how it got chosen). It still fires its click beacon if the reader expands it again. The CTA still works.

The whole flow takes one IDB read on the client, no extra server round-trip, and slots into the same BatchSelect the bootstrap was already going to send.

Revoke: when a creative is no longer eligible

Pins outlive auctions. A reader who folds an ad today might come back next month, by which time the campaign could have been paused, the creative unassigned, or the advertiser deleted. The system has to distinguish “your pin is fine, just not in this batch” from “your pin is dead, clean it up.”

Pause path. When CampaignEntity receives CampaignPaused, the AdServer’s persistedApprovedIds set has the campaign’s creative IDs removed. Next time a reader’s pin is for one of those creatives, the server’s dogearFallthrough(slot, isApproved) returns Some(DogearOutcome(honored=false, reason="creative_removed")).

Bootstrap cleanup. The bootstrap’s displayImpl reads the creative_removed reason and deletes the IDB row before the next render. The pin is gone; the slot runs a normal auction.

The full mechanism is documented in the pin-honoring chapter. The key thing for this chapter is: the bookmark protocol terminates cleanly. A revoked creative doesn’t leave dead IDB rows; an active-but-not-in-this-batch creative doesn’t lose its pin.

What this replaces

Retargeting, basically — but reader-driven instead of advertiser-driven.

Traditional retargetingDog-ear pin
Advertiser drops a tracking pixel; server logs the visitReader explicitly folds the ad’s corner
Identifier joined across sites via DSP/DMPIDB row scoped to the publisher’s origin
Reader can’t see or edit their own profileReader can clear it like any browser data
Bid premium for “high-intent” retargeting impressionsRe-encounter is free for the advertiser, free for the reader
Server-side privacy compliance burdenNo personal data crosses the wire — no compliance burden

The premise is that an explicit reader vote is a stronger signal than any inferred preference. A reader who folds your ad has told you, by a deliberate gesture, that they want to come back to it. That’s worth more than a probabilistic match against their browsing history — and it costs nothing to honor.

Source of truth

  • modules/core/src/main/scala/promovolve/common/FoldToken.scala — token mint + verify
  • modules/api/src/main/scala/promovolve/api/TrackRoutes.scala/v1/dogear-event handler
  • modules/api/src/main/scala/promovolve/api/ServeRoutes.scalafoldTokenFor, mints token at serve time
  • modules/api/src/main/scala/promovolve/api/LearningEventLog.scalalogFold / logUnfold
  • modules/core/src/main/scala/promovolve/publisher/delivery/AdServer.scala — pin-honor + dogearFallthrough (see Pin-Honoring)
  • platform/banner-bootstrap/src/dogear-storage.ts — IDB schema and TTL math
  • platform/banner-bootstrap/src/bootstrap.ts — pin submission, re-encounter, revoke cleanup
  • platform/banner-component/src/banner.ts — fold/unfold UI events

Fluid Creatives

A Promovolve creative is authored once and renders into any rectangle the publisher offers. Same creative, 300×250 sidebar on desktop, 375px-wide phone slot, 970×90 leaderboard — proportions preserved, no separate variants. This chapter explains how that works and why it’s not just “responsive design” applied to ads.

The IAB matrix problem

Traditional programmatic ads ship as pixel-locked IAB units: 300×250, 728×90, 970×250, 160×600, 336×280, 320×50. A campaign that wants to run across every reasonable inventory size has to produce all of them — a creative-production matrix the small-business advertiser doesn’t have the resources for.

The mobile gap makes it worse. A reader landing on a publisher’s mobile site has slots that don’t match any desktop IAB size. Publishers either hide the slots or serve a separately-produced mobile creative. The advertiser maintains a parallel mobile pipeline; the small-business advertiser produces nothing and gets no fill.

Fluid creatives close both gaps in one shot. There is no matrix. There is no “mobile variant.” There’s the creative.

Aspect ratio over pixel size

The trick is that the creative is authored at an aspect ratio, not a fixed pixel size. The host element’s CSS sizes itself by aspect ratio rather than by absolute width and height:

.design-box {
  container-type: size;
  aspect-ratio: ${w}/${h};
  width: 100%;
  max-width: 100%;
  max-height: 100%;
  position: relative;
  overflow: hidden;
}

width: 100%; max-width: 100% tells the box to expand into whatever space the publisher’s slot gives it. aspect-ratio keeps the height proportional. On a 300px-wide slot the box renders at 300×250; on a 200px-wide phone slot it renders at 200×167 with the same proportions.

container-type: size is what makes everything inside the box scale with it (next section).

Container queries: cqh and cqmin

Fixed-pixel typography breaks under fluid sizing. A 16px headline that looks right at 300×250 looks oversized at 200×167 and undersized at 728×90. CSS container queries solve this.

The banner’s interior dimensions all use container-relative units:

  • cqh — 1% of the container’s height
  • cqw — 1% of the container’s width
  • cqmin — 1% of min(width, height)
  • cqmax — 1% of max(width, height)

So a headline sized at 8cqh is always 8% of the container’s height, regardless of how big or small the container is. The dog-ear flap is 6cqmin square — 6% of the smaller dimension. Page navigation uses 12cqmin on desktop and 7cqmin on mobile (the cover-page picker tightens up at small sizes so it doesn’t dominate the slot).

The result: every text size, every padding, every icon, every animation distance scales with the container. A creative authored at 300×250 retains its visual hierarchy at 200×167 or 728×90 — proportionally smaller text, proportionally narrower padding, but the same composition.

No px inside the design-box. That’s the rule the designer enforces and the runtime preserves.

The publisher slot

The other half of the contract is the slot the publisher renders into. The reference pattern lives in modules/examples/publisher-site-ja/index.html:

.ad-slot {
  width: 100%;
  margin: 16px auto;
}
.ad-slot[data-ad-width="728"][data-ad-height="90"]   { max-width: 728px; aspect-ratio: 728/90;  }
.ad-slot[data-ad-width="970"][data-ad-height="90"]   { max-width: 970px; aspect-ratio: 970/90;  }
.ad-slot[data-ad-width="300"][data-ad-height="250"]  { max-width: 300px; aspect-ratio: 300/250; }
.ad-slot[data-ad-width="336"][data-ad-height="280"]  { max-width: 336px; aspect-ratio: 336/280; }
.ad-slot[data-ad-width="160"][data-ad-height="600"]  { max-width: 160px; aspect-ratio: 160/600; }
.ad-slot[data-ad-width="320"][data-ad-height="50"]   { max-width: 320px; aspect-ratio: 320/50;  }

Attribute selectors keyed off data-ad-width and data-ad-height apply the right max-width and aspect-ratio per slot. The slot fills its parent’s width up to the authored maximum, then preserves the aspect ratio. On a 1100px-wide desktop layout, a 300×250 slot renders at 300×250. On a 375px-wide phone, it renders at 300×250 if the column is wide enough, or scales down proportionally if it isn’t.

A @media (max-width: 768px) rule stacks the sidebar below the main content for phone layouts. Slot sizing inside the sidebar still works — same aspect-ratio rules apply.

Combined: publisher slot is fluid (responsive), creative is fluid (aspect-ratio + container-queries), and the two compose. A 300×250 creative in a 200px-wide slot fills the slot at 200×167 with everything proportional.

What this isn’t

It’s not “responsive web design” in the sense of the page reflowing into a single column on mobile. The creative’s composition doesn’t reflow — text doesn’t wrap into a different column count, images don’t crop differently, layout items don’t rearrange. The whole creative scales like a vector image, just smaller.

That distinction matters because it preserves the design intent. A creative laid out elegantly at 300×250 stays elegant at 200×167. A reflow-style “responsive” creative would have to pick a new layout for each breakpoint, which is exactly the multi-variant production problem fluid creatives solve in the first place.

It’s also not pixel-perfect at every size. A creative authored at 728×90 (a wide leaderboard) looks ridiculous in a 300×250 sidebar — too short for that aspect ratio. Fluid creatives scale to fit, but they don’t reshape; an authored aspect ratio that’s wildly mismatched to the slot still looks bad. The IAB-mode lock in the designer is one tool advertisers use to commit to a specific aspect ratio; the LP-to-creative pipeline (LP-to-Creative) is the other, generating per-aspect-ratio variants when the source LP supports it.

Authoring discipline

The “fluid everywhere” property is fragile. It requires that nothing inside the design-box uses a fixed pixel size — one stray font-size: 14px and the creative breaks at small slots. The discipline is enforced in three places:

  • The runtime banner. banner.ts uses cqmin / cqh / cqw for every interior dimension. No px literals on layout-driven properties.
  • The designer. The creative editor’s canvas exposes the authored dimensions to the user, but the values written into the saved creative are aspect-ratio-relative. The designer enforces this; users can’t accidentally write a fixed-pixel layout.
  • The reference publisher site. The pattern at modules/examples/publisher-site-ja/index.html is the canonical responsive-slot recipe. Anyone integrating with Promovolve can copy that block directly.

When adding new editor surfaces or new component types, the rule is “container queries inside, aspect-ratio + max-width outside.” Don’t regress to hardcoded pixels.

Why this matters for the product story

Fluid creatives close the gap that excludes most advertisers from programmatic. The local restaurant doesn’t run a 300×250 / 728×90 / 970×250 / 160×600 / 336×280 / 320×50 production pipeline — they have one ad. The mid-market brand doesn’t maintain a parallel mobile creative team — they have one ad. The agency doesn’t bill two production rounds — they bill one.

It also makes the LP-to-Creative pipeline economically viable. If the pipeline had to generate seven IAB-sized variants per landing page, the cost-per-creative would balloon. Generating one fluid creative cuts that to a single Playwright + Gemini + designer pass.

And it composes cleanly with the magazine format. Expanding into a full-screen overlay isn’t a separate creative; it’s the same creative rendered at the viewport’s aspect ratio. Container queries handle the resize automatically.

Source of truth

  • platform/banner-component/src/banner.ts — host sizing (aspect-ratio, container-type: size), interior dimensions in cqmin / cqh
  • platform/banner-component/src/render-overlay.ts — overlay sizing
  • platform/creative-designer/src/render/canvas.ts — designer enforces the model
  • modules/examples/publisher-site-ja/index.html — canonical responsive-slot pattern

The LP-to-Creative Pipeline

A small-business advertiser doesn’t have a creative team. They have a landing page. The LP-to-creative pipeline turns that landing page into a magazine creative — pages, copy, layouts, images — automatically. It’s what makes the magazine format economically viable for advertisers without a production budget.

This chapter walks through the five stages of the pipeline and the Gemini and Playwright machinery behind each.

What the advertiser does

Three inputs:

  1. Paste a landing page URL.
  2. Pick an ad product category (IAB Ad Product Taxonomy 2.0).
  3. Set a daily budget and max CPM.

The dashboard runs the pipeline, presents a preview, and lets the advertiser tweak before publishing. From the advertiser’s view it’s “paste URL → see ad” with optional editing in between. Everything below is what runs server-side to make that happen.

The five stages

URL                                                          Created creative
 │                                                                  ▲
 ▼                                                                  │
┌─────────────┐  ┌─────────────┐  ┌──────────────┐  ┌──────────┐  ┌────────────────┐
│ 1. Analyze  │→ │ 2. Rewrite  │→ │ 3. Generate  │→ │ 4. Save  │→ │ 5. Background  │
│ Playwright  │  │ Gemini 2.5  │  │ layouts      │  │ creative │  │ asset finalize │
│ extracts    │  │ Flash       │  │ Gemini 3.1   │  │ (pages + │  │ (images +      │
│ sections    │  │ → magazine  │  │ Flash-Lite   │  │ layouts) │  │ banner PNG +   │
│             │  │ pages       │  │ → PC+Mobile  │  │          │  │ category check)│
└─────────────┘  └─────────────┘  └──────────────┘  └──────────┘  └────────────────┘

Stages 1–3 happen during the advertiser’s onboarding flow, in real time. Stage 4 is the save. Stage 5 runs in the background after save returns.

Stage 1: LP analysis (Playwright)

LPAnalyzer.analyze(url) spins up a headless Chromium, navigates to the URL, and waits for the page to settle. Why a real browser instead of an HTTP fetch:

  • Many landing pages render content with JavaScript. An HTTP fetch returns the empty SPA shell.
  • Hero images are often loaded lazily; without rendering them, the analyzer can’t capture them as image references.
  • Some pages are gated behind cookie banners or anti-bot checks that need a real browser context (the stealth integration is shared with this path).

Once the page is rendered, the analyzer extracts a structured representation:

LPAnalysisResult(
  title:    String,             // <title> or og:title
  sections: Vector[LPSection],  // headings + body text
  images:   Vector[LPImage],    // hero + inline images with alt text
  locale:   (lang, country, tz) // inferred from URL TLD + page meta
)

Sections come from a heading-based segmentation (h1/h2/h3 + their following paragraphs). Images are captured with their natural dimensions and alt text. Locale is inferred so downstream stages can decide whether the rewrite should be in Japanese, English, or whatever the source page is.

The result is JSON; the dashboard surfaces a section picker that lets the advertiser choose which sections to include in the magazine. Up to ~5 sections work well; more than that and the magazine drags.

Stage 2: Section rewrite (Gemini 2.5 Flash)

LPExtractor.rewriteSections(sections) takes the picked sections and asks Gemini to rewrite them into magazine-style page copy. The system prompt is opinionated:

  • Same language as the source. A Japanese LP rewrites into Japanese, not translated to English.
  • Editor voice, not marketer voice. Buzzwords like “unlock”, “elevate”, “seamless”, “transform”, “game-changing” are explicitly banned.
  • Field-length budgets per page. headline 10–30 chars, sub 20–60, body 80–220, caption 10–40. Tight enough that the result reads like ad copy and not paraphrased web copy.
  • Design tokens. Each page gets a tag (FEATURE / EXPERIENCE / PLAN / STORY / CTA), an accent hex color, a dark-gradient bg, and an imgEmoji for visual punch.
  • Last page only is the CTA. isCTA: true is forced on the final page.

The output is a JSON array of BannerPage objects, one per input section, ordered as the advertiser arranged them. This is the structured copy that becomes the magazine’s pages.

Why Gemini 2.5 Flash specifically: it’s the cheapest Gemini that produces consistent JSON output at this prompt length. Latency is ~3-5 seconds; cost is fractions of a cent per call. Both matter — onboarding is interactive, and the pipeline runs once per advertiser per creative.

Stage 3: Layout generation (Gemini 3.1 Flash-Lite)

A page with copy isn’t a creative yet. The copy needs to live on a canvas — text positioned, sized, colored, with images placed and animations cued. That’s the layout.

LPExtractor.generateLayoutPair(page) calls Gemini once per page with both the page content and a layout-prompt that asks for:

  • PC layout at 16:9 aspect ratio.
  • Mobile layout at 9:16 aspect ratio.

Both in one call. The “pair” is deliberate — generating them together produces variants that read as the same composition reflowed, not two unrelated layouts that happen to share copy. Item positions, font weights, and animation cues are paired across PC and mobile so a reader looking at the same page on different devices sees a recognizable design.

The output is a JSON LayoutItem[] — text items with (left, top, fontSize, color), image items with crops, animations with (targetX, targetY, duration, delay). The same schema the designer uses for hand-authored creatives, which means hand-authored and AI-generated creatives are indistinguishable downstream.

The model here is Gemini 3.1 Flash-Lite (preview), not 2.5 Flash. The structured-output quality at the layout-grammar level is meaningfully better in 3.1, and the cost is similar. Override with LAYOUT_MODEL env var if a future model wins on this task.

Stage 4: Save (designer → API)

The designer takes the rewritten pages, the generated layouts, and any tweaks the advertiser made, and assembles them into a BannerConfig plus a Pages array. It POSTs to:

POST /advertiser/creatives/save        (Go dashboard)
POST /v1/advertisers/{id}/campaigns/{id}/creatives  (Scala API)

The Scala API’s createCreativeLogic writes the creative row to CreativeRepo with the pages JSON, banner config, and reference to the campaign. At this point the creative exists in the database, but its assets aren’t finalized yet — image references still point at external URLs (the LP’s own CDN, ad-hoc Imgur uploads, etc.) and there’s no rendered banner PNG for the dashboard’s thumbnail.

The save returns immediately. Stage 5 runs asynchronously.

Stage 5: Asset finalize (CreativeProcessor)

After save, the API sends a Process command to the CreativeProcessor actor (the artist formerly known as RichCreativeProcessor). Three sub-stages:

5a. Image import. External image references in the pages are downloaded with Pekko HTTP, content-addressed by SHA-256, and stored in R2 via ImageStorage. The pages JSON is rewritten so references point at our CDN. After this step, the creative is self-contained — no external image dependencies at serve time. If the LP’s CDN goes down a year later, the ad still serves.

5b. Banner render. LPAnalyzer.renderBanner(pagesJson, w, h) spins up Playwright (the same Chromium used in stage 1), loads the magazine-banner web component with the assembled pages and config, paints it, and screenshots the result as a PNG. The PNG goes into R2. This becomes:

  • The thumbnail in the creative list.
  • The static-image fallback for legacy ad-tag flows.
  • The image the publisher’s approval queue shows so the publisher can decide before the creative serves.

5c. Category verification. If a CategoryVerificationClient is wired, the rendered banner + the advertiser’s declared adProductCategory are sent to Gemini. The model says “this banner is consistent with Travel” or “this looks like Adult content not Travel.” The result lands on the creative record:

  • A match flips status to Active (creative enters the auction pool).
  • A no-match flips it to Flagged for publisher review.

skipVerify=true (used for draft saves) runs sub-stages 5a and 5b but stops before 5c, leaving status as Draft. Drafts get a real thumbnail in the list — the advertiser can resume editing without losing their place.

Idempotency, retries, and failure modes

The pipeline has obvious failure points: an LP that never finishes loading, Gemini returning malformed JSON, an image URL that 403s, R2 timing out. Each stage handles these:

  • LP fetch timeouts. Playwright runs with a 30-second navigation timeout. On timeout, the analyzer returns an empty result and the dashboard surfaces “couldn’t read this URL” with a retry button.
  • Gemini 5xx / rate limits. Both rewrite and layout calls go through HttpRetryPolicy.withRetry — up to 5 attempts, capped exponential backoff with jitter, retries on 408/429/500/502/503/504 plus network failures. The shared GeminiRateLimiter token bucket keeps total RPM under the project’s quota across all Gemini callers (rewrite, layout, taxonomy classification, category verification).
  • Image download failures. A single failed image doesn’t fail the creative; the page reference is left empty and the page renders without that image.
  • CreativeProcessor crashes mid-pipeline. On startup, the actor scans CreativeRepo for creatives with pages_json set but s3_key empty (the marker for “stage 5 didn’t finish”) and re-runs from where the previous attempt died. A restart while the pipeline is mid-flight doesn’t lose work.

Why this matters for the product story

Without the pipeline, the magazine format is a feature for advertisers who can afford a designer. With the pipeline, it’s a feature for the local restaurant.

The economic claim — “anyone with a landing page can run an ad” — depends on every step of this chain working end-to-end with no human in the loop except for the final approval. Each Gemini call costs fractions of a cent, runs in a few seconds, and produces something the advertiser can ship. The CreativeProcessor finalizes the assets in the background so the advertiser doesn’t watch a spinner.

The other half of the same claim — “and it’ll look good in any slot” — is the Fluid Creatives chapter. The pipeline produces creatives that flow; the format ensures they flow correctly.

Source of truth

  • modules/crawler/src/main/scala/promovolve/crawler/LPAnalyzer.scala — Stage 1 + Stage 5b’s renderBanner
  • modules/core/src/main/scala/promovolve/creative/LPExtractor.scala — Stage 2 (rewriteSections), Stage 3 (generateLayoutPair)
  • modules/api/src/main/scala/promovolve/api/EndpointRoutes.scalaanalyze-lp / rewrite-sections / generate-layout-pair routes
  • modules/api/src/main/scala/promovolve/api/CreativeProcessor.scala — Stage 5 orchestration
  • modules/core/src/main/scala/promovolve/publisher/assessment/CategoryVerificationClient.scala — Gemini category verification
  • modules/core/src/main/scala/promovolve/llm/HttpRetryPolicy.scala — shared retry policy for all Gemini calls
  • modules/core/src/main/scala/promovolve/GeminiRateLimiter.scala — shared RPM budget

The Designer and Banner Stack

The magazine format is a contract between two pieces of TypeScript code:

  • The designer (platform/creative-designer/) — a page builder where advertisers compose creatives. Drag, resize, rotate; type copy; pick fonts and colors; fan out across IAB sizes.
  • The banner (platform/banner-component/) — the runtime web component publishers embed. Renders the same data the designer produces.

The contract is a JSON shape: BannerConfig plus a Pages array. The designer writes it. The banner reads it. WYSIWYG isn’t an aspiration — it’s a constraint. If the same JSON renders differently in the two places, something is broken.

What the designer is

The designer is a Vite-built TypeScript bundle served by the Go dashboard. It’s not a separate app — it loads inline into a dashboard page when an advertiser clicks “Edit creative” or “Create from LP.” The bundle weighs ~135 KB gzip and has no React, no framework. State management is a tiny in-house store with subscribers; the canvas is plain DOM elements positioned with absolute coordinates.

The structural pieces:

src/
├── modes.ts          ← canvas mode catalog (Expanded PC, Mobile, IAB sizes)
├── state.ts          ← functional state updates (immutable transitions)
├── store.ts          ← in-house pub/sub store
├── types.ts          ← shared with banner-component
├── render/
│   ├── canvas.ts     ← visible canvas + selection chrome
│   ├── overlay.ts    ← drag/resize/rotate handles
│   └── rulers.ts     ← page-aware ruler guides
├── ui/
│   ├── menu-bar.ts, toolbar.ts, sidebar.ts
│   ├── canvas-header.ts, canvas-foot.ts
│   ├── props-panel.ts, layers.ts, animation-panel.ts
│   ├── page-bg-panel.ts, banner-config-panel.ts
│   ├── size-matrix.ts ← multi-size fanout view
│   ├── save.ts        ← assemble + POST to /advertiser/creatives/save
│   └── …
└── interaction/      ← pointer/keyboard handlers

Canvas modes

A creative isn’t a single layout — it’s a primary 16:9 “expanded” composition plus an optional set of IAB-sized variants (300×250, 728×90, 970×250, etc.). The MODES table in modes.ts lists all eleven:

{ key: "expanded", label: "Expanded PC (16:9)",     w: 1600, h: 900 }
{ key: "mobile",   label: "Expanded Mobile (9:16)", w: 540,  h: 960, sizeKey: "mobile-expanded" }
{ key: "300x250",  ...                                         sizeKey: "300x250" }
{ key: "728x90",   ...                                         sizeKey: "728x90" }
…etc.

The expanded mode is the master. Sized modes are fanouts — variants of the same content reflowed for that aspect ratio. The data model reflects this:

  • Expanded mode mutations target page.layout (the master layout array).
  • Sized mode mutations target page.banners[sizeKey] (the per-size fanout).

The size-matrix UI shows all sizes side-by-side as thumbnails so the author can spot mismatches and re-fan-out from the master. The LP-to-creative pipeline seeds both expanded and mobile in stage 3 (the paired Gemini call); the author can then fan out to additional IAB sizes from the matrix.

Because the format is fluid (see Fluid Creatives), most advertisers don’t need every IAB size — the expanded master scales into many slots. Sized fanouts exist for cases where the master at, say, 300×250 needs a tighter composition than a straight scale would produce.

The page model

A creative is a list of Page objects:

interface Page {
  headline?: string;
  sub?: string;
  body?: string;
  caption?: string;
  bg?: string;                   // page background color
  isCTA?: boolean;
  ctaUrl?: string;
  layout?: LayoutItem[];          // expanded layout
  banners?: Record<string, LayoutItem[]>; // per-IAB-size fanouts
  videoBg?: VideoBg;
}

Five page-level affordances the designer exposes:

  • Cover picker (★ Cover toggle in canvas-header.ts). The author marks which page is the cover — the static frame readers see in the collapsed slot. Defaults to page 0; an author whose strongest hook is page 3 can promote it. Saved as BannerConfig.coverPageIdx and consumed by the banner runtime.
  • Page background color (page-bg-panel.ts). Each page can have its own background. The expanded overlay derives its surrounding background from the cover page’s bg via color-mix with luminance flip, so the overlay feels like the cover spread across the screen.
  • Video background (page-bg-panel.ts + state.setVideoBg). Optional full-bleed video that plays under the layout in every mode. Authoring-rare; mostly used by big-brand creatives.
  • CTA flag. Marking a page isCTA: true makes its ctaUrl clickable. Layout items can opt in individually with ctaTarget=true; if no items are marked, the whole page becomes the click target.
  • Page reordering (drag in the page list). Pages are an ordered sequence; reordering shifts both the master layout and every banners[*] fanout in lockstep.

Layout items

A layout is a flat array of items. Four types share LayoutItemBase:

{ type: "text",   text, fontSize, fontFamily, color, fontWeight, textAlign, … }
{ type: "image",  src, borderRadius, crop }
{ type: "rect",   fill, stroke }
{ type: "circle", radius, fill, stroke }

Every item carries position (left, top as percentages) and size (width, height as percentages). Rotation, opacity, and a designer-side flag set (locked, hidden, _generated) work on every type.

Coordinates are percentages of the canvas, not pixels. A text item at left: 25, top: 40, width: 50 is centered horizontally and 40% from the top, regardless of whether the canvas is 1600×900 or 300×250. Combined with the container-query interior sizing on the runtime banner, this is how a single creative renders the same composition at any actual pixel size.

Animations

Each item can carry an animationTo: MotionTarget:

interface MotionTarget {
  left?, top?, rotation?, scale?, opacity?: number;
  duration?: number;
  delay?: number;
  easing?: string;  // "ease-out", "cubic-bezier(...)", etc.
}

The item starts at its base state (positioned per left/top, opacity per opacity, no rotation) and tweens to the animationTo target. Subsetting matters: only the fields present in animationTo animate. A common pattern is a fade-in entrance — base opacity: 0, animationTo opacity: 1.

Page-entrance choreography is the sum of every item’s animation. The designer’s animation-panel.ts exposes the per-item controls; preview plays the page through one cycle so the author can see the timing without reloading.

Hand-authoring vs AI-authoring: the same schema

The LP-to-creative pipeline emits LayoutItem[] arrays — the same schema the designer’s hand-authoring writes. There’s no separate “AI-generated layout” type. Generated items carry a designer-side _generated: true flag so the size-matrix can show “authored” vs “auto-layout” status pills, but any user edit on a generated item flips the whole size from generated to authored (and clears the flag).

Downstream, the runtime banner doesn’t know or care which layouts came from a human and which from Gemini. The same BannerConfig flows into the same web component the same way.

The contract with the runtime banner

The single most important file in the designer is types.ts — it re-exports the runtime banner’s BannerConfig, LayoutItem, Page, MotionTarget types directly from @promovolve/banner-component:

export type {
  BannerConfig, Page, LayoutItem, TextItem, ImageItem, RectItem,
  CircleItem, MotionTarget, VideoBg, ExpandAnimation,
} from "@promovolve/banner-component";

There is no second copy of the types. If the runtime banner adds a field, the designer’s TypeScript compiler sees it. If a designer surface needs a field the banner doesn’t render, the type-check fails. WYSIWYG enforced by the type system.

save.ts assembles the current store state into a BannerConfig + Pages[], builds a hidden form, and POSTs through /advertiser/creatives/save (Go dashboard) → POST /v1/.../creatives (Scala API). See LP-to-Creative § Stage 4.

What the runtime banner does with it

The banner is <expandable-magazine-banner> — a Web Component, Shadow DOM, no framework dependencies. It reads pages and config attributes (JSON-encoded) and renders:

  • A collapsed view from pages[config.coverPageIdx], sized to the slot’s aspect ratio.
  • An expanded overlay built lazily on first tap, with all pages rendered in a swipeable carousel.
  • The dog-ear corner (or not, depending on data-can-fold).
  • The four lifecycle beacons (impression, click, CTA, fold).

Layout items render from the same coordinate system the designer wrote. Text uses container-query sizing (cqh/cqmin) so the same percentages produce proportionally-sized text at any container size. Images use object-fit: cover plus optional crops. Animations replay using the same animationTo payloads, transformed into CSS @keyframes at runtime.

The banner reading the designer’s output should look identical to the designer’s preview. When it doesn’t, the bug is in one or the other — never in the wire format.

Why no framework

The banner has to be lightweight (~14 KB gzip currently) because it’s served from the Scala API origin on every first-time page view. Anything larger turns the publisher’s slot into a load-time penalty.

The designer doesn’t need a framework, but it could have one. The decision to skip React was driven by the same “no extra runtime dependency” rule applied to the dashboard as a whole — the dashboard is plain Go templates plus this designer bundle and a couple of small UI islands. A React-based designer would balloon the bundle and force the dashboard to ship a React runtime everywhere. Functional-immutable state + a tiny pub/sub store gives the designer everything it needs at a fraction of the size.

Source of truth

  • platform/creative-designer/src/ — the designer
  • platform/creative-designer/src/modes.ts — canvas mode catalog
  • platform/creative-designer/src/state.ts — functional state updates
  • platform/creative-designer/src/render/canvas.ts — primary render path
  • platform/creative-designer/src/ui/save.ts — the save handoff
  • platform/banner-component/src/ — the runtime banner
  • platform/banner-component/src/types.ts — the shared schema
  • platform/banner-component/src/banner.ts — runtime render
  • platform/banner-component/README.md — banner-side architecture and Playwright render flow

System Architecture

Promovolve runs as an Apache Pekko Cluster with three distinct node roles, Cluster Sharding for entity distribution, and Distributed Data (DData) for replicated in-memory state. Persistence uses PostgreSQL via JDBC (Slick).

High-Level Components

graph TB
    subgraph Cluster["Pekko Cluster (promovolve system)"]
        subgraph Singleton["Singleton Role"]
            CD["Campaign Directory"]
            Sched["Scheduler"]
        end
        subgraph Entity["Entity Role"]
            Camp["Campaign"]
            Auct["Auctioneer"]
            CatBid["CategoryBidder"]
            TaxRnk["TaxonomyRanker"]
            Adv["Advertiser"]
        end
        subgraph API["API Role"]
            HTTP["HTTP API"]
            AdSrv["AdServer"]
            Evt["Events"]
        end
        DData["DData: ServeIndex, PacingConfig, Blocklist<br/>Replicated across all nodes via gossip<br/>Durable: LMDB for shard-* and exhausted-*"]
        PG["PostgreSQL: durable_state, snapshot tables<br/>20 connections, 5 min pool"]
    end

Cluster Configuration

From application.conf:

SettingValue
Cluster rolessingleton, entity, api (env: PEKKO_CLUSTER_ROLES)
Number of shards100
Remember entitieson (via DData store)
Passivation timeout5 minutes
Split-brain strategykeep-majority (stable after 20s)
Heartbeat interval1s, threshold 12.0, acceptable pause 10s
Remote frame size256 KiB
Seed nodepekko://promovolve@127.0.0.1:25520

DData Configuration

SettingValue
Gossip interval2s
Notify subscribers interval500ms
Max delta elements500
Durable keysshard-*, exhausted-campaigns
Durable storeLMDB (100 MiB map, 200ms write-behind)
Pruning interval120s (dissemination: 300s)

Key Design Decisions

  1. 100 shards with remember-entities-via-DData ensures entities survive node restarts and are automatically rebalanced (rebalance-absolute-limit: 20, relative: 0.1).

  2. DData for ServeIndex means every API node has a local replica of all active ad candidates. Serve-time lookups never cross the network.

  3. LMDB durability for shard metadata and exhausted-campaigns state, but ServeIndex itself is ephemeral — rebuilt from auctions on restart.

  4. Separation of roles allows scaling read (API) and write (entity) workloads independently. The singleton role hosts cluster-wide coordinators like CampaignDirectory.

Entity Hierarchy & Cluster Roles

Entity Relationship Map

Advertiser (sharded by advertiserId)
  ├── Budget: dailyBudget, spendToday, lastResetEpochDay
  ├── Creatives: Map[CreativeId, Creative]
  ├── Site blocklist: Set[SiteId]
  └── Campaigns: Set[CampaignId]
        └── Campaign (sharded by advertiserId|campaignId)
              ├── Budget: dailyBudget, spendToday, maxCpm
              ├── Creative assignments: Set[CreativeId]
              ├── Spend buffer: 500ms / 20 events batching
              ├── Idempotency: BloomFilter (50K entries, 0.01% FPP)
              └── Categories: Set[CategoryId]

Publisher
  └── Site (sharded by siteId)
        ├── Config: domain, seedUrl, cronSchedule, maxDepth
        ├── PacingConfig: dayDuration, traffic shapes, warmupMode
        ├── Ad product blocklist: Set[AdProductCategoryId]
        └── Slots: List[AdSlotConfig(slotId, width, height)]

AuctioneerEntity (sharded by siteId)
  ├── Page classifications: Map[URL, Classification]
  ├── Participating campaigns: Map[CampaignId, Set[URL]]
  ├── TaxonomyRankerEntity (sharded by category|siteId)
  │     └── Thompson Sampling weights, half-life decay
  └── CategoryBidderEntity (sharded by category|siteId|shard)
        └── Virtual sharding: hash(siteId) % 5

CampaignDirectory (ClusterSingleton)
  ├── Reverse index: CategoryId → Map[CampaignId, AdvertiserId]
  ├── Routes updates via CampaignDistributor (8 workers)
  │     └── Fan-out to CategoryBidderEntity shards
  └── 60-second reconciliation cycle

Sharding Strategy

Each entity type uses a different shard key optimized for its access pattern:

EntityShard KeyShardsRationale
AuctioneerEntitysiteId100All pages on a site auction together
CategoryBidderEntitycategory|siteId|shard100 × 5 virtualDistributes load within popular categories
TaxonomyRankerEntitycategory|siteId100Co-located with bidder for low-latency
CampaignEntityadvertiserId|campaignId100Independent lifecycle, per-campaign budget and pacing state
AdvertiserEntityadvertiserId100Budget and frequency caps per advertiser
CampaignDistributorN/A8 workersRoutes by hash(categoryId) % 8

Entity Lifecycle

CampaignEntity

  • Status enum: Active, Paused
  • Active: Responds to bid requests with the campaign’s CPM (no bid optimizer — quality-adjusted second-price clearing handles price discovery)
  • Paused: Stops responding, creatives removed from ServeIndex
  • Budget exhausted: Stops bidding, creatives remain in ServeIndex (budget resets daily)
  • Day reset guard: lastRolledEpochDay prevents double-roll on same calendar day
  • Passivation: After 5 minutes of inactivity

CampaignEntity Spend Recording

The spend path is carefully designed for correctness:

  1. Buffered: 500ms timer OR batch of 20 events (whichever fires first)
  2. Idempotency: 50K-entry Bloom filter (0.01% FPP) + 50K Scaffeine cache (5min TTL)
  3. At-least-once: Pending reports retry with exponential backoff (100ms → 5s, max 5 attempts)
  4. Persist-then-publish: State saved before SpendUpdate event published

AuctioneerEntity

  • Activated on first crawl of a site’s page
  • Tracks which campaigns participated in recent auctions (for targeted re-auction)
  • Periodic re-auction: Every 5 minutes (promovolve.auction.reauction-interval)
  • Cleanup: Removes classifications older than 48 hours every 5 minutes
  • Passivates after 5 minutes of inactivity

AdvertiserEntity

  • Tracks: Set of campaigns, Map of creatives, daily budget/spend
  • Flush ID dedup: Maintains last 1000 processed flush IDs (MaxProcessedFlushIds)
  • Day reset: Based on lastResetEpochDay comparison with current epoch day

Data Flow: Crawl vs Serve

Promovolve separates its workload into two distinct phases with fundamentally different performance characteristics.

Crawl Phase (Write Path)

The crawl phase runs on a configurable schedule (default: Quartz cron "0 0 2 * * ?" — 2am daily) and is the “heavy” computation path. Crawl configuration per site includes maxDepth (default: 2) and concurrency (default: 5), running on a dedicated crawler-dispatcher with 4 fixed threads.

graph TD
    Crawler["External Crawler<br/>(4-thread pool)"] --> Classification["Page Classification<br/>(LLM: Gemini/OpenAI/Anthropic)<br/>categories + confidence scores"]
    Classification --> Auctioneer["AuctioneerEntity<br/>(sharded by siteId)"]
    Auctioneer --> Taxonomy["TaxonomyRankerEntity<br/>(800ms timeout)<br/>Thompson-sampled weights, 7-day half-life<br/>site-blend threshold: 20.0, min imps: 100"]
    Auctioneer --> CatBid["CategoryBidderEntity fan-out<br/>(5 virtual shards)"]
    CatBid --> CampDist["CampaignDistributor (8 workers)"]
    CampDist --> CampResp["CampaignEntity bid responses<br/>bidCpm = max(maxCpm × multiplier, floor)"]
    Auctioneer --> ServeIndex["Candidate shortlisting → ServeIndex<br/>(DData, WriteLocal, 120-min TTL)"]

Serve Phase (Read Path)

The serve phase handles every ad request and must be extremely fast.

graph TD
    User["User Request (page load)"] --> API["API Node (HTTP, port 8080)"]
    API --> Lookup["Lookup ServeIndex from local DData<br/>Key: siteId|slotId → Vector of CandidateView"]
    Lookup --> Recency["Content Recency Filter<br/>classifiedAtMs within 48h window"]
    Recency --> FreqCap["Frequency Cap Check<br/>(100ms timeout, fail-open)<br/>query AdvertiserEntity per user"]
    FreqCap --> Rate["Rate Tracking<br/>(synchronous EMA, 1s window, α=0.3)"]
    Rate --> Pacing["Pacing Gate (PI control)<br/>aggregate budget from CachedSpendInfo<br/>throttle probability 0.0–0.99"]
    Pacing -->|"random() < throttle"| Skip["Skip (204)"]
    Pacing -->|pass| TS["Thompson Sampling Selection<br/>sample Beta(clicks+1, non_clicks+1)<br/>score = sampledCTR × CPM^α<br/>argmax"]
    TS --> Budget["Budget Reservation<br/>CampaignEntity.Reserve +<br/>AdvertiserEntity.GetBudgetStatus"]
    Budget -->|failure| Next["Try next-best by Thompson score"]
    Budget -->|success| Serve["Serve ad"]
    Next -->|all exhausted| NoCandidates["NoCandidates (204)"]

Why Two Phases?

ConcernCrawl PhaseServe Phase
LatencySeconds OKMust be < 1ms
ComputationFull auction, LLM classificationCache lookup + Beta sampling
Fan-outMany entitiesZero (local DData)
Failure modeRetry on next crawlServe cached candidates
ScalingAdd entity nodesAdd API nodes
Dispatchercrawler-dispatcher (4 threads)Default Pekko dispatcher

This separation means:

  1. Auction complexity doesn’t affect serve latency — LLM classification and multi-entity fan-out happen in the background
  2. Serve capacity scales independently — adding API nodes increases request throughput without affecting auction load
  3. Temporary failures are invisible to users — cached candidates remain in ServeIndex until their 120-minute TTL expires

The Dashboard Projection

The dashboard advertisers and publishers look at — impressions, spend, CTR, fold counts, time-series charts — is a read model. It is not the database the actors write to, and it is not the journal the engagement pixels hit. It’s a separate set of aggregate tables, kept up to date by a streaming projection that reads from the journal and writes to the aggregates.

This chapter explains why the read side is separate, what flows through the journal, what the handler does with each event type, and where the dog-ear’s separate metrics fit in.

Why a separate projection

The serve-time write path has two hard constraints:

  • Don’t block actor mailboxes. A CampaignEntity recording a spend reservation can’t wait on a hot DB query. Serve latency is < 1ms; nothing on that path can take longer.
  • Don’t lose events. Every impression, click, and CTA needs to land somewhere durable, even if the dashboard is down or the database is paged.

Querying the same data the actors are writing to would couple dashboard read latency to actor write latency. A 200ms time-range query on a busy dashboard would slow down every serve. Event sourcing on the write side plus projections on the read side is the standard way to break that coupling: the actors emit append-only events, a separate process reads them, and the dashboard queries the read-side aggregates.

serve / track endpoints                 dashboard reads
        │                                       ▲
        ▼                                       │
┌──────────────────┐    ┌────────────────────┐ │
│ TrackingEvent    │    │  campaign_stats    │─┤
│ Journal          │    │  creative_stats    │─┤
│ (append-only,    │ →  │  campaign_hourly_* │─┤
│  Pekko Streams)  │    │  campaign_daily_*  │─┤
└──────────────────┘    │  advertiser_summary│─┘
        │               └────────────────────┘
        │                       ▲
        └───── projection ──────┘
              (Pekko, exactly-once,
               offset = sequence_nr)

The journal

tracking_events is the append-only journal. Schema (Slick definition in TrackingEventJournal.scala):

sequence_nr   bigserial PRIMARY KEY      -- monotonic offset for projection
event_type    text                       -- impression | click | cta_click | fold | unfold
event_time    timestamptz                -- wall-clock when the engagement happened
site_id       text
campaign_id   text NULL
advertiser_id text NULL
creative_id   text
category      text NULL
cpm           numeric NULL
url           text NULL
slot          text NULL
request_id    text NULL                  -- UUID (batch path) or 16-char hex hash (fold tokens)
user_id       text NULL                  -- for frequency cap analysis
dogeared      boolean DEFAULT false      -- true for impressions served via honored pin

What’s recorded:

  • Impressions (/v1/imp) — billable serves. CPM populated.
  • Clicks (/v1/click) — first-expansion event from the magazine banner. CPM not relevant here; click is recorded per-creative.
  • CTA clicks (/v1/cta) — reader tapped the call-to-action page after expanding.
  • Folds (/v1/dogear-event with event=fold) — reader bookmarked the creative. Free engagement signal.
  • Unfolds (/v1/dogear-event with event=unfold) — reader removed a bookmark.

What’s not recorded: actor-internal messages (entity state changes, pacing decisions, auction shortlisting). The journal is the engagement trail, not the system’s full event log.

Writing to the journal

TrackingEventJournal uses a Pekko Stream to batch writes:

Source.queue[TrackingEvent](bufferSize = 10000, OverflowStrategy.dropNew)
  .groupedWithin(100, 100.millis)   // batch: max 100 events or 100ms
  .mapAsync(4) { batch =>           // 4 parallel DB writes
    db.run(trackingEvents ++= batch)
  }

The shape of this pipeline answers four questions at once:

  • Backpressure? A bounded queue of 10K events with OverflowStrategy.dropNew — under sustained DB outage, new events drop rather than holding actor threads or eating heap. Logged when it happens.
  • Throughput? Up to 100 events per round-trip × 4 parallel inserts. Plenty for the kinds of traffic Promovolve targets, with headroom.
  • Latency? 100ms max wait before flushing a partial batch, so dashboard freshness lags by less than 200ms (one batch wait + one projection poll).
  • Durability? Events fully cross the wire to the DB before the stream reports done. The trade-off is that an event in flight at server crash time is lost — fold tokens and click HMACs both make that idempotent on retry, so a lost event is at worst a missed impression count, not a billing or pin error.

TrackEvent (the in-memory shape used by LearningEventLog) becomes TrackingEvent (the persisted shape) inside each writeXxx method. The transformation is mechanical — copy the relevant fields, set eventType, derive eventTime from e.ts.

Running the projection

DashboardProjection.init wires it up as a ShardedDaemonProcess:

ShardedDaemonProcess(system).init(
  name = "DashboardProjection",
  numberOfInstances = 1,            // single partition; can scale later
  behaviorFactory = { partition =>
    val projectionId = ProjectionId("dashboard", s"partition-$partition")
    val sourceProvider = new TrackingEventSourceProvider(dbConfig, partition, 1)
    val projection = SlickProjection.exactlyOnce(
      projectionId,
      sourceProvider,
      databaseConfig = dbConfig,
      handler = () => new DashboardProjectionHandler
    )
    ProjectionBehavior(projection)
  },
  settings = ShardedDaemonProcessSettings(system),
)

Three things to notice:

  • exactlyOnce — the projection’s offset is committed transactionally with the read-side update. A handler that processes event N and then crashes mid-write rolls back both the read-side update and the offset commit, so on restart the same event is reprocessed and lands exactly once.
  • ProjectionId("dashboard", "partition-0") — one logical projection. Today there’s one partition; scaling to N partitions sharded by siteId hash % N is a configuration change, not a rewrite.
  • Source provider pollsTrackingEventSourceProvider polls tracking_events every 500ms for rows with sequence_nr > lastOffset. Not a notification system; deliberately simple. PostgreSQL handles 100s of polls/sec from a single process without breaking a sweat.

The handler

DashboardProjectionHandler dispatches by eventType:

override def process(event: TrackingEvent): DBIO[Done] = event.eventType match {
  case "impression" => processImpression(event)
  case "click"      => processClick(event)
  case "cta_click"  => processCTAClick(event)
  case "fold"       => processFold(event)
  case "unfold"     => processUnfold(event)
  case other => log.warn("Unknown event type: {}", other); DBIO.successful(Done)
}

Each per-event-type method is a single transaction (updates.transactionally) that updates every aggregate the event affects.

The aggregates

processImpression writes to five tables in one transaction:

TableGranularityPer-impression delta
campaign_statsper campaignimpressions += 1, total_spend += cpm/1000, last_impression_at, optional first_impression_at
creative_statsper (creative, campaign)impressions += 1, total_spend += cpm/1000
campaign_hourly_statsper (campaign, hour bucket)impressions += 1, spend += cpm/1000
campaign_daily_statsper (campaign, day bucket)impressions += 1, spend += cpm/1000, unique_sites += 1
advertiser_summaryper advertisertotal_impressions += 1, total_spend += cpm/1000

Bucketing is wall-clock-truncation, UTC:

val hourBucket = e.eventTime.truncatedTo(ChronoUnit.HOURS)
val dayBucket  = e.eventTime.atZone(ZoneOffset.UTC).toLocalDate

All five inserts use INSERT … ON CONFLICT … DO UPDATE (PostgreSQL upsert). First impression for a campaign creates the row; later impressions increment counters atomically. No read-modify-write, no race conditions.

processClick and processCTAClick follow the same pattern with their own counter columns (clicks, cta_clicks). processFold / processUnfold write to dog-ear-specific counters covered below.

The dashboard’s queries hit these tables directly. Time-series charts read from campaign_hourly_stats or campaign_daily_stats; campaign-detail pages read from campaign_stats; advertiser overviews read from advertiser_summary. None of them touch the journal.

The dog-ear wing

Folds and “dogeared impressions” are the format-specific metrics:

  • Folds (the bookmark gesture itself) — counted on processFold. Free engagement signal, no spend involved.
  • Unfolds — counted on processUnfold. Used to compute the pin retention rate: (folds − unfolds) / folds. An advertiser sees how many readers actually came back versus folded then changed their minds.
  • Dogeared impressions — impressions served because of an honored pin. The journal flags them with dogeared = true; processImpression calls bumpDogearedImpression which adds to a parallel set of dogeared_impressions counters across campaign_stats / creative_stats / campaign_hourly_stats / campaign_daily_stats.

The key thing is that dogeared impressions are still billable impressions — they roll up into the primary impressions and total_spend counters like any other serve, AND into the dogeared_impressions counter as a separate dimension. Wait, that contradicts Pin-Honoring, where pinned re-encounters bypass clearing entirely…

It actually doesn’t, but the seam needs explaining. The pin-honor path emits a BatchSlotOutcome with clearingPrice = CPM.zero and skips reservation. When the bootstrap fires the impression beacon, the cpm field on that beacon is zero. So in the journal: dogeared = true, cpm = 0. In the aggregates: impressions += 1, total_spend += 0. Dogeared impressions are counted but priced free, exactly as the pin-honoring chapter describes.

The dashboard surfaces this as a sub-counter: “Impressions: N (of which dogeared: M)” and “Spend: $X” where the dogeared portion contributes nothing to spend. Advertisers see how much of their reach is reader-driven rather than auction-driven, and it’s free.

Backfill and replay

If a schema change adds a new aggregate column or a new event type, the projection can be rewound:

  1. Stop the ShardedDaemonProcess.
  2. Reset the offset (UPDATE pekko_projection_offset_store SET current_offset = 0 WHERE projection_name = 'dashboard').
  3. Truncate the affected aggregate tables (or selectively recompute).
  4. Restart the daemon.

The projection re-reads tracking_events from sequence_nr 0 and rebuilds the aggregates. Per-event handler logic is idempotent under upsert semantics, so the rebuild produces the same numbers regardless of whether it ran once or N times. A full rebuild on a busy site takes minutes, not hours; the journal is bounded by the engagement rate, not the actor message rate.

The simulation script scripts/run-dev.sh --fresh does exactly this as part of its DB reset, so a fresh dev environment always has a known-good projection state.

Source of truth

  • modules/api/src/main/scala/promovolve/api/projection/TrackingEventJournal.scala — journal write path + Slick table
  • modules/api/src/main/scala/promovolve/api/projection/DashboardProjection.scalaShardedDaemonProcess setup + custom SourceProvider
  • modules/api/src/main/scala/promovolve/api/projection/DashboardProjectionHandler.scala — per-event-type aggregate updates
  • docker/init-db.sql — read-side table definitions (campaign_stats, creative_stats, campaign_hourly_stats, campaign_daily_stats, advertiser_summary, plus their dogeared_impressions columns)
  • modules/api/src/main/scala/promovolve/api/LearningEventLog.scala — produces the TrackEvent shape that becomes journal rows

Periodic Batch Auction

The defining architectural choice of Promovolve is that auctions run ahead of time, not per-request. When content is crawled (default schedule: 2am daily via Quartz cron), the system runs a full multi-phase auction and caches results in DData for instant serve-time lookups.

Auction Pipeline

┌─────────────────────────┐
│ Page Classification     │  LLM-based (Gemini/OpenAI/Anthropic)
│                         │  → IAB categories + confidence scores
└────────┬────────────────┘
         ▼
┌─────────────────────────┐
│ Category Ranking        │  TaxonomyRankerEntity per (category, site)
│                         │  → Thompson-sampled weights, 7-day half-life
└────────┬────────────────┘
         ▼
┌─────────────────────────┐
│ Bid Collection          │  CategoryBidderEntity (5 virtual shards)
│                         │  → CampaignDistributor (8 workers)
│                         │  → CampaignEntity bid responses
└────────┬────────────────┘
         ▼
┌─────────────────────────┐
│ Candidate Shortlisting  │  Fair selection: 1 per campaign, fill remainder
│                         │  → Top K per slot (default K=3)
└────────┬────────────────┘
         ▼
┌─────────────────────────┐
│ ServeIndex Caching      │  DData WriteLocal, 120-minute TTL
│                         │  → Replicated to all API nodes via gossip
└─────────────────────────┘

Periodic Re-Auction

Between crawl cycles, the system runs periodic re-auctions every 5 minutes (promovolve.auction.reauction-interval) for recent content within the 48-hour recency window. Additionally, event-driven re-auctions trigger on campaign/advertiser state changes.

Content Recency Window

Only pages classified within the last 48 hours participate in auctions. Every 5 minutes, AuctioneerEntity runs cleanup to remove classifications older than 48 hours.

Key Configuration

ParameterValueEnv Var
Re-auction interval5 minutesREAUCTION_INTERVAL
Content recency48 hours
Crawl cron schedule"0 0 2 * * ?"Per-site config
Crawl max depth2Per-site config
Crawl concurrency5Per-site config
ServeIndex TTL120 minutes
Taxonomy ask timeout800ms

Phase 1: Page Classification

Before any auction can run, the system must understand what a page is about. Page classification maps URLs to IAB Content Taxonomy 2.1 categories with confidence scores using LLM-based analysis.

Two Taxonomies, One Match

Promovolve uses two distinct IAB taxonomies that meet at auction time:

TaxonomyVersionWho sets itPurpose
Ad Product Taxonomy2.0Advertiser“What is my product?” (e.g., Travel, Kitchen Equipment)
Content Taxonomy2.1LLM classifier“What is this page about?” (e.g., Destinations, Outdoor Recreation)

The advertiser never sees content categories. They pick their product category, and ContentToAdProductMapping derives the matching content categories using the official IAB mapping file (content_2.1_to_ad_product_2.0.tsv). If no direct mapping exists for a product category, the system walks up the taxonomy’s parent chain until it finds one.

At auction time, matching is exact: the page’s content category must be in the campaign’s derived content category set. There is no fuzzy or hierarchical matching at bid time — the hierarchy is resolved once, at campaign setup.

Classification Pipeline

Promovolve supports multiple LLM providers for classification, configured in application.conf:

ProviderConfig KeyEnv Var
Geminipromovolve.gemini.api-keyGEMINI_API_KEY
OpenAIpromovolve.openai.api-keyOPENAI_API_KEY
Anthropicpromovolve.anthropic.api-keyANTHROPIC_API_KEY

Gemini is enabled by default (promovolve.gemini.enabled = true).

Classification Output

The LLM returns category IDs which are normalized to IAB Content Taxonomy 2.1 numeric IDs. Legacy IAB 1.0 format IDs (e.g., "IAB17") are converted via TieredCategory.normalize() to their 2.1 equivalents (e.g., "483"). The result is a map of category-to-confidence:

{
  "url": "https://example.com/sports/nba-finals-recap",
  "categories": {
    "483": 0.92,
    "484": 0.85,
    "393": 0.45
  }
}

Each Confidence value is an opaque Double in [0, 1]. All downstream matching uses these numeric Content Taxonomy 2.1 IDs.

Top-K Category Selection

AuctioneerEntity selects the top K categories (default K=3) by confidence score. Only these categories proceed to ranking and bidding.

Classification Storage

Classifications are stored in AuctioneerEntity’s state as a Map[URL, Classification], keyed by page URL and timestamped with classifiedAtMs. Every 5 minutes, a cleanup task removes entries older than the 48-hour recency window.

Role in Scoring

The confidence score feeds into category ranking:

categoryScore = classifierConfidence × rankerWeight

This composite score is stored in CandidateView.categoryScore and used as a prior for Thompson Sampling during cold start.

Phase 2: Category Ranking

After page classification identifies the top K categories, each category is assigned a ranker weight that reflects how well ads in that category have historically performed on this specific site.

TaxonomyRankerEntity

Each (category, siteId) pair has its own TaxonomyRankerEntity. Configuration from application.conf:

ParameterDefaultEnv Var
Half-life7 daysTAXONOMY_RANKER_HALF_LIFE
Prior α1.0TAXONOMY_RANKER_PRIOR_ALPHA
Prior β1.0TAXONOMY_RANKER_PRIOR_BETA
Flush interval5 secondsTAXONOMY_RANKER_FLUSH_EVERY
Site blend threshold20.0
Site min impressions100.0TAXONOMY_RANKER_SITE_MIN_IMPRESSIONS
Site stats max age14 days
Max sites per category5000TAXONOMY_RANKER_MAX_SITES

Weight Calculation

The ranker uses Thompson Sampling with a Beta-Bernoulli model:

  1. Maintain per-category click/impression counts for this site
  2. Model CTR as Beta(prior_α + clicks, prior_β + non_clicks) — default prior is Beta(1, 1) (uniform)
  3. Sample from the Beta distribution to get a weight
  4. Return sampled weight to AuctioneerEntity

Site Blending

When a specific site has fewer than site-min-impressions (100) observations, the ranker blends site-specific statistics with global category statistics using the site-blend-threshold (20.0). This prevents new sites from suffering cold-start issues.

Fan-Out and Timeout

AuctioneerEntity queries all K TaxonomyRankerEntities in parallel with an 800ms timeout.

If a ranker doesn’t respond within 800ms:

  • Use cached weight with half-life decay: weight × 0.5^(ageSeconds / halfLifeSeconds)
  • Where halfLifeSeconds = 7 days = 604800s by default
  • Fall back to prior weight (0.5) if no cached data exists

Stats Lifecycle

  • Stats older than site-stats-max-age (14 days) are pruned
  • Per-category site count is capped at max-sites-per-category (5000)
  • Stats are flushed to persistence every flush-every (5 seconds)

Final Category Score

categoryScore = classifierConfidence × rankerWeight

This score propagates to CandidateView.categoryScore and serves as the Thompson Sampling prior during cold start at serve time.

Phase 3: Bid Collection

For each selected category, the system fans out to all active campaigns and collects bids. This is the most distributed phase of the auction.

End-to-End Matching: From Ad Product to Page Content

To understand bid collection, it helps to see the full chain that connects an advertiser’s product to a publisher’s page. Here’s a concrete example with a gym campaign:

Campaign setup (happens once):

  1. Advertiser creates a campaign and selects ad product: “Gyms and Health Clubs” (IAB Ad Product 1512)
  2. ContentToAdProductMapping.getContentForAdProduct("1512") looks up the IAB mapping
  3. No direct mapping for 1512 → walks up to parent 1510 (Fitness Activities)
  4. 1510 maps to content categories {225, 227} (Fitness and Exercise, Running and Jogging)
  5. Campaign stores categories = Set(225, 227) — these are the content types this campaign will bid on
  6. CampaignDirectory registers the campaign under categories 225 and 227
  7. CategoryBidderEntity for categories 225 and 227 now knows this campaign exists

Page crawl (happens per page):

  1. SiteEntity collects demand categories from all active campaigns → {225, 227}
  2. buildTaxonomyCandidates expands these with descendants → {226, 227} (Participant Sports, Running and Jogging)
  3. This becomes the candidate list sent to Gemini — the LLM only sees categories that active campaigns are targeting
  4. Gemini classifies the page text using only those categories
  5. If it returns 225 or 227 with sufficient confidence, AuctioneerEntity fans out bid requests to CategoryBidderEntity
  6. CategoryBidderEntity routes to the gym campaign
  7. Campaign bids → candidate created → queued for publisher approval

Key design decisions:

  • The LLM prompt is constrained to demand categories — it only classifies into categories that have active campaigns. This saves tokens and avoids classifying content nobody is advertising for.
  • Hallucinated category IDs (where the LLM returns an ID not in the candidate list) are filtered out — only valid matches produce auctions.
  • The advertiser never sees content categories. They pick their product; the IAB mapping handles the rest.

CategoryBidderEntity

Each (category, siteId) pair uses 5 virtual shards to distribute load. The shard is selected by hash(siteId) % 5, so the actual entity key is category|siteId|shardIndex.

CampaignDistributor

Within each CategoryBidderEntity, a CampaignDistributor manages fan-out to individual campaigns using 8 worker actors, routed by hash(categoryId) % 8.

Bid Request → Response

Each CampaignEntity evaluates the request and responds with eligible creatives. The bid CPM is simply the advertiser’s max CPM:

bidCpm = max(maxCpm, floorCpm)

Where:

  • maxCpm: The campaign’s configured maximum CPM
  • floorCpm: The publisher’s floor price (auto-optimized by the floor CPM agent)

The advertiser bids their true value. There’s no bid shading or multiplier — the quality-adjusted pricing at serve time ensures they only pay what’s needed to win, not their full bid.

Eligibility Filters (Campaign-Side)

A CampaignEntity will not respond if any of these checks fail:

  1. Category mismatch: The page category is not in the campaign’s categories set — this is the primary filter. The campaign’s categories are derived from its Ad Product Taxonomy 2.0 ID via ContentToAdProductMapping, which maps to a set of Content Taxonomy 2.1 IDs. Matching is exact: state.categories.contains(pageCategory)
  2. Category blocklisted: The category is in the campaign’s categoryBlocklist (explicit exclusions)
  3. Status paused: Campaign status != Active
  4. Budget exhausted: dailyBudget - (spendToday + bufferedSpend) <= 0
  5. Day-aware check: If the calendar day changed since lastResetInstant, the budget is treated as fresh (reset happens lazily)
  6. Site blocklisted: Publisher’s site is on the advertiser’s siteBlacklist
  7. No matching sizes: None of the campaign’s allowedSizes fit the slot’s AdSlotConfig(width, height)

Aggregation Rules

The CategoryBidderEntity aggregates responses:

  1. CPM threshold: Only candidates within top 80% of the highest CPM are kept: cpm ≥ maxCpm × (1.0 - 0.80). This is deliberately wide — quality-adjusted pricing at serve time handles differentiation among competitive bids.
  2. Campaign cap: Maximum 50 campaigns per category (maxCampaignsPerCategory), ranked by CPM descending
  3. One creative per campaign: The highest-CPM creative wins if a campaign has multiple eligible creatives

Response Structure

Each eligible creative is wrapped in a Candidate:

Candidate(
  creativeId: CreativeId,
  campaignId: CampaignId,
  advertiserId: AdvertiserId,
  cpm: CPM,                              // bidCpm from above
  category: CategoryId,
  creativeHash: String,
  landingDomain: String,
  preApproved: Boolean,
  frequencyCap: Option[Int],
  adProductCategory: Option[AdProductCategoryId]
)

Phase 4: Candidate Shortlisting

This is the critical phase where Promovolve diverges from traditional auctions. Instead of selecting a single winner, it passes all competitive candidates to serve-time Thompson Sampling, using a fair ordering algorithm that guarantees per-campaign diversity.

Fair Candidate Selection Algorithm

The shortlisting algorithm ensures each campaign gets representation before any campaign gets a second slot:

1. Collect all CampaignBidResponses across all categories
2. Group by campaign → pick best creative per campaign (by CPM)
3. If #campaigns ≥ #slots:
     Take top campaigns by CPM, one creative each
4. Else (fewer campaigns than slots):
     Each campaign gets 1 slot (guaranteed representation)
     Fill remaining slots with next-best creatives from existing campaigns
5. Record participating campaigns → Map[CampaignId, Set[URL]]

Why This Algorithm?

This ensures that 3 campaigns with 1 creative each will all be represented in a 3-slot configuration, rather than having one high-CPM campaign fill all 3 slots. Only when there are fewer campaigns than slots does any campaign get multiple creatives in the shortlist.

Campaign Participation Tracking

AuctioneerEntity maintains:

participatingCampaigns: Map[CampaignId, Set[URL]]

This enables targeted re-auction: when a campaign’s state changes, the system knows exactly which pages are affected.

CandidateView Structure

Each shortlisted candidate is stored as a CandidateView:

CandidateView(
  creativeId: CreativeId,
  campaignId: CampaignId,
  advertiserId: AdvertiserId,
  assetUrl: CDNPath,         // URI to CDN-hosted creative asset
  mime: MimeType,            // imageJpeg, imagePng, imageGif, imageWebp, videoMp4
  width: Int,
  height: Int,
  category: CategoryId,
  cpm: CPM,
  classifiedAtMs: Long,      // when the page content was classified
  categoryScore: Double,     // classifierConfidence × rankerWeight (default 0.5)
  frequencyCap: Option[Int],
  adProductCategory: Option[AdProductCategoryId],
  landingDomain: String
)

Note: impression and click statistics are tracked separately in CreativeStats at the AdServer level, not stored in the CandidateView itself. This allows stats to accumulate across auction cycles.

Standard Ad Sizes

Promovolve supports IAB standard sizes defined as AdSize opaque type (Int, Int):

NameSize
Medium Rectangle300 × 250
Leaderboard728 × 90
Wide Skyscraper160 × 600
Mobile Banner320 × 50
Billboard970 × 250
Half Page300 × 600
Large Mobile Rectangle320 × 100

Image assets are subject to the IAB LEAN Ad limit: max file size 50 KiB (promovolve.image-limits.max-file-size, configurable via IMAGE_MAX_FILE_SIZE).

Phase 5: ServeIndex Caching

The final auction phase stores shortlisted candidates in the distributed in-memory cache (ServeIndex) for instant retrieval at serve time.

ServeIndex Write

After shortlisting, AuctioneerEntity writes the candidate set to ServeIndex:

Key:   siteId|slotId
Value: ServeView(
         candidates: Vector[CandidateView],
         version: Long,       // auction timestamp
         expiresAtMs: Long     // currentTimeMillis + TTL
       )

Write Semantics

OperationConsistencyUse Case
Put (full replacement)WriteLocalFresh auction results
Append (single candidate)WriteLocal + dedup by creativeIdAdding orphaned creative
RemoveWriteMajority(800ms) + retry (max 5, backoff 200ms)Creative/campaign takedown
CPM updateWriteLocalBest-effort CPM refresh
FilterByCreativeIdsWriteLocalKeep only valid creatives

TTL

Each entry has a default TTL of 120 minutes. On budget exhaustion events, TTL is refreshed to dayDurationSeconds × 1.1 × 1000ms to ensure entries survive until the next daily budget reset.

Replication

ServeIndex uses Pekko DData with gossip-based replication:

  • Gossip interval: 2 seconds
  • Notify subscribers: 500ms
  • Max delta elements: 500 per gossip round

Every API node gets a complete local copy within seconds of a write.

Bucketing

Entries are partitioned into 32 buckets (power-of-2) by hash of the key. Each bucket is an independent LWWMap[String, ServeView]. This keeps CRDT delta sizes small — an update to one bucket doesn’t generate deltas for entries in other buckets.

Removal Operations

ServeIndex supports granular removal:

  • RemoveCampaignFromKey: Remove all candidates from a specific campaign across slots
  • RemoveCreativeFromKey: Remove a specific creative across all slots
  • RemoveBySite: Batch removal for all slots on a site

All removals use WriteMajority with retries for durability.

Quality-Adjusted Pricing

The auction’s scoring rule and its pricing rule are two halves of the same mechanism. The score decides who wins; the price decides what they pay. This chapter is about the second half.

For the score itself — score = sampledCTR × CPM^α and the publisher’s α dial — see Scoring Formula. This chapter assumes you know the score and asks: given that score, what does the winner actually pay?

The clearing formula

When a slot has at least two eligible candidates, the winner pays the smallest CPM at which their score still beats the runner-up’s score:

clearingCpm = (bestLoserScore / sampledCTR_winner) ^ (1/α)

Clamped to [siteFloor, winner.cpm] — never below the publisher’s floor, never above what the campaign actually bid.

This is the inverse of the score formula. Substitute clearingCpm back into score = sampledCTR × CPM^α and you get exactly bestLoserScore — a tie. One cent more, the winner still beats the runner-up. So clearingCpm is the minimum bid that would have still won.

A worked example

Default α = 0.5 (sqrt). Two candidates competing for one slot:

Winner:     bid $5.00, sampled CTR 4.0%
Runner-up:  bid $4.00, sampled CTR 2.5%

Winner score:    0.04 × √5    = 0.0894     ← higher, wins
Runner score:    0.025 × √4   = 0.0500

What does the winner pay?

clearingCpm = (0.0500 / 0.04) ^ (1 / 0.5)
            = 1.25 ^ 2
            = $1.5625

The winner bid $5.00 but pays $1.5625. Sanity check: at $1.5625 the winner’s score would be 0.04 × √1.5625 = 0.04 × 1.25 = 0.05 — exactly the runner-up’s. Anything above $1.5625, the winner still wins. Anything below, they lose.

The 69% discount from bid to clearing is the quality discount: the winner’s CTR was 1.6× the runner-up’s, and the auction translated that quality gap into a price gap. A campaign that earns clicks pays less per impression than one that merely outbid the field.

Edge cases

No runner-up. If only one candidate is eligible for a slot (after the per-campaign dedup, the size match, and the floor filter), there’s nothing to clear against. The winner pays the site’s floor:

bestLoserScore = 0  →  clearing = siteFloor

This matches the per-slot Solo path’s semantics. A bidder alone in a slot pays the publisher’s reserve, not their bid.

Zero CTR. If the winner’s sampled CTR is zero (degenerate cold-start sampling), the formula divides by zero. Falls back to floor.

Non-positive α. Defensive — if α ≤ 0 the formula is undefined. Falls back to floor.

Pathologically high runner-up. If the formula produces a clearing above the winner’s bid (a runner-up so strong that the winner barely won), clamps to the winner’s bid. Campaigns never owe more than their max CPM.

Pathologically low. If the formula produces a clearing below the publisher’s floor (a runner-up so weak that the math collapses), clamps to the floor.

The fallback rule across all degenerate cases is the same: charge floor. That’s the safest default — never overcharge, always respect the publisher’s reserve.

Why this makes campaign-side bid optimization pointless

In a first-price auction (winner pays their bid), advertisers have to shade their bid: bid below their true value to capture surplus. Bid too high and you overpay; bid too low and you lose the auction. There’s a sweet spot, and the sweet spot depends on what competitors are doing. So a sophisticated DSP runs a bid optimizer — typically reinforcement learning — to find the right shading factor.

In Promovolve’s auction, the price is set by the runner-up, not by the winner’s bid. So bidding higher than your true value can’t make you pay more (the runner-up doesn’t move when you do). And bidding below your true value can only cost you — if you’d have won at honest value but lost at the shaded value, you lost an impression that was profitable for nothing.

The dominant strategy reduces to: bid your true value. The auction extracts the rest.

This is why Promovolve has no campaign-side reinforcement-learning agent. A previous version had per-campaign DQN agents tuning bid multipliers; they were removed because there was nothing for them to learn. The auction mechanism handles what the agent was trying to handle.

Family resemblance: GSP, not VCG

Single-slot Promovolve auctions are equivalent to Vickrey/VCG: winner pays the smallest bid that beats the runner-up given their CTR. Truthful bidding is the dominant strategy.

Multi-slot Promovolve auctions are Generalized Second-Price (GSP) with quality scoring — the same mechanism class historic Google AdWords used. Each slot independently clears against its own per-slot runner-up. This is not VCG: a true VCG implementation would compute the externality the winner imposes on the entire allocation by re-running the assignment with the winner removed, and price each slot at that marginal welfare difference. Promovolve doesn’t do that — it just looks at the second-best score in each slot’s eligible set.

The difference matters when claiming game-theoretic properties:

  • Single slot: provably truthful (Vickrey).
  • Multi-slot: approximately truthful (GSP). Pathological coordinated-lying scenarios exist in theory but require collusion among multiple bidders, and the equilibrium is “close enough to truthful” in practice that nobody bothers gaming it. Microsoft Bing tried switching AdWords-style auctions from GSP to true VCG in 2007 and rolled back — VCG’s marginal-welfare math is too sensitive to noisy CTR estimates and the pricing was hard to explain to advertisers.

The book chapters that say “honest bidding is the dominant strategy” are accurate for single slots and a fair approximation for multi-slot batches. They don’t claim VCG.

Pinned re-encounters bypass clearing

A slot that carries a dog-ear pin and finds the pinned creative still in the auction pool bypasses pricing entirely:

clearingPrice = CPM.zero

The pin is a reader’s bookmark; the re-encounter is a free engagement signal, not a billable serve. No CPM clearing runs, no budget reservation, no pacing throttle. See Pin-Honoring at Serve Time for how the pin path slots into the broader pipeline.

Implementation

The pricing formula lives in one place:

ThompsonSampling.qualityAdjustedClearing(
    winnerSampledCtr: Double,
    winnerBid: CPM,
    bestLoserScore: Double,
    alpha: Double,
    siteFloor: CPM,
): CPM

Pure function, no actor state. Used by:

  • AdServer.pickBestForSlot — computes clearing at pick time against the slot’s per-slot runner-up
  • AdServer.batchAssign — same logic for the legacy/test entry point
  • AdServer.batchReserveWithRetry — threads the clearing price through reservation, BatchSlotOutcome.clearingPrice, and the pending-spend delta. Reservation reserves at clearing, not at bid; pending-spend tracks clearing, not bid.

The formula contract is pinned by QualityAdjustedClearingSpec — no-runner-up → floor, zero CTR → floor, α≤0 → floor, α=0.5/0.7 numeric correctness, clamp up to bid, clamp down to floor. Future refactors can’t silently regress to first-price clearing without breaking those tests.

Source of truth

  • modules/core/src/main/scala/promovolve/publisher/delivery/ThompsonSampling.scalaqualityAdjustedClearing, cpmScore, scoreCandidate
  • modules/core/src/main/scala/promovolve/publisher/delivery/AdServer.scalabatchAssign, pickBestForSlot, batchReserveWithRetry
  • modules/core/src/test/scala/promovolve/publisher/delivery/QualityAdjustedClearingSpec.scala — formula contract

Re-Auction & Event Triggers

Between crawl cycles, the system keeps the ServeIndex fresh through periodic and event-driven re-auctions.

Periodic Re-Auction

AuctioneerEntity runs a full re-auction every 5 minutes (promovolve.auction.reauction-interval, env: REAUCTION_INTERVAL) for all pages within the 48-hour content recency window.

Event-Driven Re-Auction

Campaign-Level Events (Targeted)

These trigger re-auction only for pages where the affected campaign participated (using the participatingCampaigns map):

EventServeIndex ActionRe-Auction Scope
CampaignBudgetExhaustedRefresh TTL (keep entry)Participating pages
CampaignBudgetResetRefresh TTLParticipating pages
CampaignPausedRemove from ServeIndexParticipating pages
CampaignAdProductChangedRemove from ServeIndexParticipating pages
CpmUpdatedUpdate CPM in ServeIndexParticipating pages
CreativeStatusChanged(isActive=false)Remove creativeParticipating pages

Advertiser-Level Events (Full Site)

These affect all campaigns under an advertiser:

EventServeIndex ActionRe-Auction Scope
AdvertiserBudgetExhaustedRefresh TTL (keep entry)All recent pages on site
AdvertiserBudgetResetRefresh TTLAll recent pages on site
AdvertiserSuspendedRemove from ServeIndexAll recent pages on site

Budget Exhaustion: Keep, Don’t Remove

When a campaign or advertiser budget is exhausted, creatives are not removed from ServeIndex. Instead:

  1. TTL is refreshed to dayDurationSeconds × 1.1 × 1000ms (extends past next budget reset)
  2. The serve-time pacing gate checks budget before serving
  3. If budget is exhausted, the candidate is skipped and Thompson Sampling selects another
  4. When budget resets (next day), the creative resumes serving without re-auction

Why? Budget exhaustion is temporary — budgets reset daily. Removing and re-inserting entries would:

  • Create unnecessary DData churn (WriteMajority removes are expensive)
  • Lose the creative’s approval status
  • Require a full re-auction to restore the entry

Permanent Removal

Only these events warrant actual removal from ServeIndex:

  • Creative paused/deactivated
  • Campaign paused
  • Campaign ad product category changed (may violate publisher blocklist)
  • Advertiser suspended

These use WriteMajority consistency with up to 5 retries and 200ms initial backoff.

Content Cleanup

Every 5 minutes, AuctioneerEntity prunes classifications older than 48 hours from its internal Map[URL, Classification], ensuring stale content naturally ages out.

Published Events

Re-auction and budget events are published as domain events (extending CborSerializable) for cross-entity coordination:

  • SpendUpdate: Published every ~500ms or 20 events from CampaignEntity, includes dailyBudget, todaySpend, dayStart
  • PendingCreativesQueued: Triggers SSE notifications for publisher approval workflow

Why Multi-Candidate?

The decision to keep multiple candidates per slot — rather than selecting a single auction winner — is the most important architectural choice in Promovolve.

The Problem with Single-Winner Auctions

In a traditional ad exchange, each auction produces one winner:

  1. Exploitation trap: The highest bidder always wins, even with terrible CTR
  2. No exploration: No mechanism to discover if a lower-bidding creative performs better
  3. Fragile serving: If the winner’s budget runs out, the system must re-auction or show nothing
  4. Misaligned incentives: Exchange optimizes for revenue, not user experience

How Multi-Candidate Solves This

Promovolve’s fair selection algorithm guarantees per-campaign diversity (one creative per campaign first, then fill remainder), and Thompson Sampling explores among them at serve time.

The Scoring Formula

score = sampledCTR × CPM^α

Where sampledCTR is drawn from Beta(clicks + 1, non_clicks + 1) using time-bucketed statistics (1-minute granularity, 60-minute rolling window). The exponent α (bidWeight) is publisher-configurable: α=0.3 (Discovery) lets quality dominate, α=0.5 (Balanced) is the default sqrt(CPM), α=0.7 (Revenue) tilts toward higher bids.

Exploration in Action

Slot candidates after fair selection (α=0.5):
  Campaign A: CPM $5.00, Beta(6, 146)    → sample: 0.032
  Campaign B: CPM $4.20, Beta(3, 19)     → sample: 0.091
  Campaign C: CPM $3.80, Beta(1, 1)      → sample: 0.647

Scores:
  A: 0.032 × √5.00 = 0.032 × 2.236 = 0.0716
  B: 0.091 × √4.20 = 0.091 × 2.049 = 0.1865
  C: 0.647 × √3.80 = 0.647 × 1.949 = 1.261

→ C wins (exploration of unknown creative)

Graceful Degradation

When Campaign A exhausts its budget:

  1. Pacing gate checks budget before Thompson Sampling
  2. Campaign A is filtered out
  3. Thompson Sampling runs on B and C only
  4. No re-auction needed — no DData operations
  5. When A’s budget resets next day, it resumes serving (entry was kept in ServeIndex)

Publisher Alignment

The sampledCTR factor naturally favors creatives users actually click on. High-CPM but low-CTR creatives lose to engaging ones over time, aligning publisher interests (engagement, user trust) with advertiser interests (actual clicks).

The Trade-off

Multi-candidate selection means the highest bidder doesn’t always win. This reduces short-term CPM revenue but increases:

  • Long-term revenue: Better CTR → more clicks → better campaign ROI → higher advertiser retention
  • System resilience: Fallback candidates reduce re-auction frequency
  • Learning: Thompson Sampling converges to the best performer without any exploration rate to tune

Publisher Creative Approval

In traditional ad tech, publishers have no say over what appears on their site. The exchange picks a winner, and the publisher’s ad server renders it — sight unseen. If an inappropriate ad slips through, the publisher’s only recourse is to file a complaint after the fact.

Promovolve inverts this. Every creative must be approved by the publisher before it can be shown to readers. This isn’t a bolt-on compliance feature — it’s a core design constraint that shapes the auction system, the multi-candidate architecture, and the serving pipeline.

Why Approval Matters

Magazine advertising always had publisher approval. An editor at a cooking magazine would review every ad before it ran — no gambling ads next to a recipe, no competitor ads next to a feature article. The publisher’s editorial judgment was part of the product.

Promovolve restores this for the web. A publisher running a Japanese travel blog can:

  • Approve a ryokan ad that complements their Kyoto temple article
  • Reject a fast-food chain ad that doesn’t fit their editorial voice
  • Block entire ad product categories (gambling, alcohol) site-wide
  • Revoke a previously approved creative if their standards change

This is also why Promovolve uses multi-candidate auctions. If the system only picked one winner and the publisher rejected it, the slot would be empty. With multiple candidates queued, rejecting one simply promotes the next.

The Approval Lifecycle

A creative goes through distinct states as it moves through the system:

stateDiagram-v2
    [*] --> Pending: Auction Result
    Pending --> Approved: Publisher approves
    Approved --> Serving: Added to ServeIndex
    Pending --> Rejected: Publisher rejects
    Approved --> Revoked: Publisher revokes

1. Auction produces candidates

The AuctioneerEntity shortlists multiple candidates per ad slot and sends them to the AdServer. Each candidate carries a preApproved flag — but the AdServer doesn’t trust it blindly.

2. AdServer determines actual approval status

Instead of relying on the preApproved flag (which comes from a probabilistic Cuckoo filter and can have false positives), the AdServer queries the ServeIndex to see which creatives are actually serving:

existingCreativeIds =
    creatives in this slot's ServeIndex
  + creatives approved at any other slot on this site (inverted index)
  + creatives loaded from DB on startup (persisted approvals)

This three-source merge means:

  • A creative approved at one slot is recognized site-wide
  • Approvals survive process restarts (loaded from PostgreSQL)
  • Re-auctions don’t re-queue already-approved creatives

3. Partition: approved vs pending

The AdServer partitions candidates into two groups:

approved = candidates whose creativeId is in existingCreativeIds
pending  = everything else

Approved creatives go straight to the ServeIndex — they’re already trusted. The AdServer fetches their category scores from the TaxonomyRankerEntity, builds CandidateView objects with CDN asset URLs and dimensions, and writes them to DData. They can be served immediately.

Pending creatives are queued in the PendingSelectionStore (PostgreSQL) for the publisher to review. They cannot be served until approved.

4. Blocklist filtering

Before any of this happens, candidates are filtered against two blocklists:

  • Domain blocklist: Publishers can block specific landing domains. A creative linking to a competitor’s site is filtered out before it ever reaches the pending queue.
  • Ad product category blocklist: Publishers can block entire product categories (e.g., gambling, alcohol, firearms). Distributed via DData, this filter runs at auction time.

Blocked creatives are silently dropped — they never appear in the publisher’s approval queue.

The Pending Queue

The pending queue is the publisher’s inbox for new creatives. It’s persisted in PostgreSQL (table: pending_selection) so it survives restarts.

Data model

Each pending entry is a Selection — an ordered list of candidates for a specific (publisher, URL, slot) combination:

Selection
  publisherId: String
  url:         String
  slotId:      String
  ordered:     Vector[Candidate]   — ranked by CPM
  idx:         Int                 — index of current candidate being reviewed
  state:       Pending
  expiresAt:   Instant             — TTL-based expiration

The idx pointer tracks which candidate the publisher is currently reviewing. When a creative is rejected, the pointer advances to the next candidate.

Key operations

OperationWhat happens
upsertPendingWrite/overwrite a pending selection for a slot
getPendingFetch current pending for a slot
pendingQueueList all pending items for a publisher (for the dashboard)
removeCreativeFromPendingRemove a specific creative after approval, keep the rest
rejectAndPromoteReject current candidate, advance to next in queue
purgeExpiredClean up expired selections (TTL-based)
flagCreativeQuarantine a creative with a reason (for later review)
unflagCreativeReturn a quarantined creative to the pending queue

Budget exhaustion cleanup

When a campaign or advertiser runs out of budget, their creatives are removed from the pending queue — there’s no point asking the publisher to review an ad that can’t pay:

EventCleanup
Campaign budget exhaustedremoveByCampaignId — remove all pending creatives for this campaign
Advertiser budget exhaustedremoveByAdvertiserId — remove all pending creatives for this advertiser
Creative pausedremoveCreativeFromAll — remove from all pending slots
Landing domain blockedremoveByLandingDomain — remove all creatives with this domain
Ad product category blockedremoveByAdProductCategory — remove all creatives in this category

The Three Publisher Actions

Approve

The publisher reviews a pending creative and approves it:

  1. Validate the creative ID matches the current candidate in the queue
  2. Fetch category scores from TaxonomyRankerEntity
  3. Build a CandidateView with CDN asset URL, dimensions, and metadata
  4. Append to ServeIndex via DData — the creative is now live
  5. Persist approval to PostgreSQL (insertApproved) — survives restarts
  6. Update AdvertiserEntity with ApprovalStatus.Approved
  7. Remove from pending queue
  8. Broadcast SSE event: approved

The creative begins serving to readers on the next page load.

Reject

The publisher reviews a pending creative and rejects it:

  1. Update AdvertiserEntity with ApprovalStatus.Rejected — recorded in a Bloom filter so the creative won’t be re-submitted in future auctions for this site
  2. Remove from ServeIndex (if it was somehow there)
  3. Call rejectAndPromote to advance the queue to the next candidate
  4. If the queue is exhausted (no more candidates), trigger a re-auction so other campaigns can fill the slot
  5. Broadcast SSE event: rejected

Rejection is permanent for this site — the Bloom filter prevents the same creative from appearing in future pending queues.

Revoke

The publisher changes their mind about a previously approved creative:

  1. Remove from ServeIndex — the creative stops serving immediately
  2. Clear from both approved and rejected filters in AdvertiserEntity
  3. Broadcast SSE event: revoked

Unlike rejection, revocation is reversible — the creative can be re-queued for approval later (e.g., after the advertiser updates it).

Bulk approve

For publishers who trust an advertiser or want to quickly clear their queue:

POST /v1/publishers/{publisherId}/sites/{siteId}/creatives/bulk-approve

Approves all pending creatives for a slot in one operation. Each creative goes through the same approval flow (ServeIndex update, DB persistence, AdvertiserEntity notification). A single SSE event (bulk-approved) is broadcast with the count.

Real-Time Notifications (SSE)

Publishers don’t have to poll for new creatives. Promovolve streams events in real time via Server-Sent Events:

GET /v1/publishers/{publisherId}/sites/{siteId}/events

Event types

EventWhenPayload
pending-updatedNew creatives queued for reviewsiteId, url, slotId, count, topCreativeId
approvedCreative approved and now servingsiteId, url, slotId, creativeId
rejectedCreative rejectedsiteId, url, slotId, creativeId
bulk-approvedMultiple creatives approved at oncesiteId, url, slotId, approvedCount
revokedApproval revoked, creative removed from servingsiteId, creativeId
creative-status-changedCreative paused or reactivated by advertisercreativeId, campaignId, status
campaign-status-changedCampaign status changedcampaignId, status
heartbeatKeep-alive ping(empty, every 30 seconds)

Architecture

The PendingEventHub actor manages SSE subscribers grouped by site:

PendingEventHub
  └── subscribers: Map[siteId → Set[ActorRef[PendingEvent]]]
  • Site-specific events (pending, approved, rejected) go to subscribers for that site
  • Cross-site events (creative-status-changed, campaign-status-changed) broadcast to all subscribers
  • Subscribers auto-unsubscribe when the SSE stream terminates
  • Stale subscribers are cleaned up via actor death-watch

Pre-Approved: The Auction Tiebreaker

When the AuctioneerEntity sorts candidates, pre-approved creatives get a tiebreaker advantage:

sort key = (-CPM, if preApproved then 0 else 1)

At equal CPM, a pre-approved creative ranks higher than an unapproved one. This has two effects:

  1. Faster time-to-serve: Pre-approved creatives skip the pending queue and go straight to the ServeIndex, so they start earning impressions sooner
  2. Re-auction stability: When a re-auction runs, already-approved creatives maintain their position rather than being displaced by new, unapproved ones that would sit in the queue

How Approval Enables Multi-Candidate Auctions

The approval workflow is the reason Promovolve uses multi-candidate auctions in the first place. Consider the alternative:

Single-winner auction without approval: The exchange picks one winner. It starts serving immediately. The publisher sees an ad for online gambling on their children’s education blog. Damage done.

Single-winner auction with approval: The exchange picks one winner. The publisher rejects it. The slot is empty until the next auction. Readers see no ad. Revenue is zero.

Multi-candidate auction with approval: The auction shortlists three candidates. The publisher rejects the first one. The second candidate is already queued and ready. The slot is never empty. Revenue continues. The publisher maintains editorial control.

This is the design that makes approval practical at scale — without it, publisher approval would mean empty slots and lost revenue every time a creative is rejected.

Approval Persistence

Approvals are stored in two places for different purposes:

StoragePurposeSurvives restart?
ServeIndex (DData)Fast serve-time lookupsNo (ephemeral, rebuilt from auctions)
PostgreSQL (approved_creatives)Approval state of recordYes
keysByCreative (in-memory inverted index)Site-wide approval recognitionNo (rebuilt from ServeIndex on startup)
persistedApprovedIds (loaded from DB)Bootstrap approvals on startupYes (loaded from PostgreSQL)

On startup, the AdServer loads persistedApprovedIds from PostgreSQL. When a re-auction runs, creatives in this set are recognized as already approved and skip the pending queue — the publisher doesn’t have to re-approve creatives they already reviewed.

Thompson Sampling from Scratch

You have three ads for a travel blog. You don’t know which one readers prefer. How do you find out — without wasting thousands of impressions on the worst one?

This is the multi-armed bandit problem, and Thompson Sampling is Promovolve’s answer. This chapter builds the intuition from zero.

The Problem: Explore vs Exploit

Imagine you’re in a casino with three slot machines. Each has a different (unknown) payout rate. You have 1,000 coins. How do you maximize your winnings?

  • Pure exploitation: Play the first machine that pays out, stick with it forever. Problem: you might have gotten lucky. The other machines might be better.
  • Pure exploration: Play all three equally, 333 times each. Problem: you’re wasting coins on the worst machine long after you know it’s bad.

The optimal strategy is somewhere in between: explore early to learn which machine is best, then gradually shift to exploiting the best one. This is the explore-exploit trade-off.

In Promovolve, the “slot machines” are ad creatives. The “payout” is whether the user clicks. The “coins” are impressions — each one costs the advertiser money and uses the publisher’s ad slot. You want to show the best-performing creative most of the time, but you also need to try new or uncertain ones to learn if they’re better.

A Bad Solution: A/B Testing

The standard approach to “which creative is best?” is A/B testing: show each creative to an equal number of users, wait for statistical significance, then declare a winner and show it to everyone.

This works, but it’s wasteful. If creative A has a 5% click rate and creative B has a 0.5% click rate, equal splitting means half your traffic sees the bad creative for the entire test duration. And when the test ends, you stop learning — if a new creative arrives, you need to start a new test.

What you really want is a system that:

  1. Tries each creative a few times
  2. Quickly figures out which ones are good
  3. Shifts traffic toward the good ones
  4. Never completely stops trying — in case a creative’s performance changes

Thompson Sampling does all of this automatically.

The Key Idea: Uncertainty as Exploration

Here’s the core insight. Instead of tracking a single number (“creative A has a 5% click rate”), track your uncertainty about that number.

After 2 impressions and 1 click, you think creative A has about a 50% click rate — but you’re not very sure. It could be anywhere from 10% to 90%.

After 200 impressions and 10 clicks, you think it has about a 5% click rate — and you’re fairly confident. It’s probably between 3% and 8%.

Thompson Sampling uses this uncertainty directly: sample a random value from each creative’s uncertainty distribution, then pick the creative with the highest sample.

A creative you know little about has a wide distribution — sometimes it samples high, sometimes low. So it gets tried occasionally (exploration). A creative with lots of data has a narrow distribution centered on its true click rate. It samples consistently near its actual performance (exploitation).

Exploration happens naturally, proportional to uncertainty. No tuning parameters. No explicit explore/exploit switch.

Beta Distributions: Modeling Click Rates

A click rate is a probability: a number between 0 and 1. The Beta distribution is the natural way to represent uncertainty about a probability.

A Beta distribution has two parameters: α (alpha) and β (beta).

  • α counts “successes” (clicks) plus a prior
  • β counts “failures” (impressions without clicks) plus a prior

Starting from Beta(1, 1) — a uniform distribution, meaning “I have no idea, any click rate from 0% to 100% is equally possible” — each observation updates the distribution:

Start:       Beta(1, 1)         — uniform, total ignorance
1 click:     Beta(2, 1)         — probably high CTR, but uncertain
1 no-click:  Beta(2, 2)         — back toward 50%, still very uncertain
8 no-clicks: Beta(2, 10)        — probably low CTR (~17%), getting more sure
2 clicks:    Beta(4, 10)        — ~29%, narrowing

The mean of Beta(α, β) is α / (α + β). But Thompson Sampling doesn’t use the mean — it samples a random value from the distribution. That’s what makes it work.

A Worked Example

Three creatives cached for a travel blog ad slot. After some impressions:

CreativeImpressionsClicksDistributionMean CTR
A (hotel ad)1506Beta(7, 145)4.6%
B (tour ad)202Beta(3, 19)13.6%
C (new airline ad)00Beta(1, 1)50.0%

A reader loads the page. Thompson Sampling draws one random sample from each:

A: sample from Beta(7, 145)  → 0.038  (probably near its true ~5%)
B: sample from Beta(3, 19)   → 0.091  (wide distribution, sampled somewhat high)
C: sample from Beta(1, 1)    → 0.647  (uniform distribution, sampled very high)

Creative C wins this round — not because we think it has a 65% click rate, but because we know nothing about it and the sample happened to be high. This is exploration.

If C turns out to have low CTR, after 20 impressions its distribution narrows (say, Beta(2, 19)) and it stops sampling high. If C actually has great CTR, its distribution stays high and it earns more impressions.

The system converges on the truth without ever deciding to “start a test” or “end a test.”

Scoring: Combining CTR with Bid Price

Click rate isn’t the only thing that matters. The publisher also cares about revenue. A creative with 2% CTR at $8 CPM might be more valuable than one with 3% CTR at $2 CPM.

Promovolve’s scoring formula balances both:

score = sampledCTR × CPM^α

Why CPM^α instead of just CPM? The exponent α (publisher-tunable, default 0.5) compresses the CPM range so a creative has to perform well to win consistently — you can’t just outbid everyone with a terrible ad.

Consider two creatives at the default α=0.5:

  • A: $2 CPM, 4% CTR → score = 0.04 × √2 = 0.04 × 1.41 = 0.057
  • B: $10 CPM, 1% CTR → score = 0.01 × √10 = 0.01 × 3.16 = 0.032

Creative A wins despite bidding 5× less. Bidding 5× more gives you only ~2.2× the CPM term — quality dominates.

The publisher chooses the exponent: α=0.3 (Discovery) tilts harder toward quality, α=0.7 (Revenue) tilts back toward higher bids. See Scoring Formula for the full dial.

This aligns publisher and advertiser incentives: the publisher gets revenue AND engaged readers, not just the highest bidder’s money.

Cold Start: What About Brand New Creatives?

When a creative has zero impressions, its distribution is Beta(1, 1) — uniform. It can sample anywhere from 0 to 1. This gives it a natural exploration boost, but it’s random.

Promovolve adds structured cold start strategies:

Full cold start (all creatives have 0 impressions): Use the category score from the auction as a prior, plus small random noise. This bootstraps from the content classification — a travel ad on a travel page starts with a reasonable guess.

Warmup (all creatives have fewer than 10 impressions): Round-robin by impression count. The creative with the fewest impressions goes next. This ensures every creative gets a minimum number of trials before Thompson Sampling takes over.

Partial cold start (mix of new and established creatives): New creatives get a 30% exploration boost — they’re selected with epsilon-greedy probability even if their sample is low.

Steady state (all creatives have 10+ impressions): Pure Thompson Sampling. The distributions are informative enough to drive good decisions.

These strategies are in ThompsonSampling.scala, specifically the select() method.

Time-Bucketed Statistics

Click rates change over time. An ad that performed well last week might be stale now. Promovolve uses a 60-minute rolling window with 1-minute granularity:

  • Impressions and clicks are recorded in 1-minute buckets
  • When scoring, only the last 60 buckets are counted
  • Older data automatically falls off

This means:

  • The system adapts within an hour if a creative’s performance changes
  • A creative that was good this morning but bad this afternoon gets corrected quickly
  • The Beta distribution is always based on recent, relevant data

Where Thompson Sampling Sits in the Pipeline

Thompson Sampling doesn’t run on every request. It sits behind several gates:

Request arrives
  → Content recency check (is the page fresh enough?)
  → Frequency cap check (has this user seen this ad too many times?)
  → Rate tracking (record this request for pacing)
  → Pacing gate (PI controller: should we serve or skip?)
  → Thompson Sampling (which creative to show?)
  → Budget reservation (does the winning campaign have budget?)
  → Serve the ad

The pacing gate runs before Thompson Sampling. This is important: if the pacing controller decides to skip this request (to conserve budget), Thompson Sampling never runs. This prevents exploration from being wasted on throttled requests.

Why Thompson Sampling Over Other Approaches

vs Epsilon-Greedy (ε-greedy): Show the best creative 90% of the time, random 10%. Simple, but the exploration rate is fixed and doesn’t adapt. A creative you’ve shown 10,000 times still gets explored at the same rate as one you’ve shown 10 times. Thompson Sampling naturally explores uncertain creatives more.

vs UCB (Upper Confidence Bound): Pick the creative with the highest mean + confidence_bonus. Deterministic — same state always picks the same creative. Thompson Sampling’s randomness is a feature: two users loading the same page at the same time might see different creatives, which produces diverse data faster.

vs A/B Testing: Fixed allocation, fixed duration, manual setup. Thompson Sampling is continuous, adaptive, and automatic.

From Theory to Code

ConceptFileKey method
Beta sampling (Marsaglia-Tsang)ThompsonSampling.scalasampleBeta()
Score = sampledCTR × CPM^αThompsonSampling.scalacpmScore() / scoreCandidate()
Cold start strategiesThompsonSampling.scalaselect()
Time-bucketed creative statsAdServer.scalaCreativeStats
Pacing gate before TSSelectionLogic.scalashouldServe() then select()

The next chapters cover each of these components in detail with exact formulas and configuration values.

Thompson Sampling (MAB)

Thompson Sampling is the core serve-time algorithm in Promovolve. It selects which creative to show from the shortlisted candidates, balancing exploration of uncertain options with exploitation of known performers.

The Algorithm

For each serve request (after pacing gate and frequency cap filtering):

For each candidate c in the slot:
  stats = creativeStats[c.creativeId]  // 1-minute bucketed, 60-min window
  imps = stats.totalImpressions
  clicks = stats.totalClicks
  folds = stats.totalFolds

  if imps == 0:
    sampledCTR  = categoryScore + random(-0.15, +0.15)
    sampledFold = sampleBeta(1, 1)              // uniform [0,1] cold prior
  else:
    sampledCTR  = sampleBeta(clicks + 1, imps - clicks + 1)
    sampledFold = sampleBeta(folds  + 1, imps - folds  + 1)

  engagement  = sampledCTR + FoldWeight × sampledFold + newcomerBonus(imps)
  score       = engagement × CPM^α

Select candidate with highest score

Two probabilistic signals drive the choice. CTR (clicks per impression) is the canonical click-likelihood proxy. Fold rate (dog-ear bookmarks per impression) is a stronger intent signal — folding takes deliberate effort, where a click could be impulsive — so it carries weight FoldWeight = 2.0 against CTR’s 1.0 in the engagement combiner. See Beta-Bernoulli Model for why fold-rate fits the same Beta-conjugate framework.

The newcomer bonus (a UCB-flavored additive term) tilts the auction toward creatives that haven’t yet had a chance to prove themselves. It decays linearly to zero as the creative accumulates its first 50 impressions, after which the candidate competes purely on its own posteriors. See Cold Start Strategies for the full curve.

The CPM^α factor ensures bid price matters with diminishing returns. The exponent α (bidWeight) is publisher-configurable: at the default α=0.5, a $10 CPM is only ~3.2× better than a $1 CPM (not 10×). See Scoring Formula for the full publisher dial.

Time-Bucketed Statistics

Unlike simple counters, Promovolve tracks impressions and clicks in 1-minute time buckets over a 60-minute rolling window:

case class CreativeStats(
  // minute → (impressions, clicks, folds)
  buckets: Map[Long, (Int, Int, Int)] = Map.empty,
  windowMinutes: Int = 60
)

On each impression, click, or fold:

val minute = now.getEpochSecond / 60
val (imps, clks, folds) = buckets.getOrElse(minute, (0, 0, 0))
// Update the relevant counter, then prune old buckets:
buckets.filter { case (min, _) => min > cutoffMinute }

Folds share the same bucket as impressions and clicks at the same minute — three counters travel together so the fold posterior tracks the click posterior in lockstep, without needing a separate persistence path.

Why time-bucketed?

  • Automatic recency: old data prunes naturally, no manual decay needed
  • Late click handling: a click at 10:22 for an impression at 10:15 creates a new bucket entry — both contribute to totals
  • Clean window: exactly 60 minutes of data, not “all time” which would make exploration decay too slowly
  • Persistence: stats snapshot to DB hourly, loaded on startup via CreativeStatsLoaded

Selection Pipeline Position

Thompson Sampling runs after the pacing gate and frequency cap filter:

ServeIndex lookup → Content recency → Frequency cap → Pacing gate → Thompson Sampling → Budget reservation

This ordering is critical — the pacing gate decides whether to serve at all (volume gating), while Thompson Sampling decides which creative to show (choice). Running pacing before TS prevents exploration bias.

Sub-chapters

Beta-Bernoulli Model

Thompson Sampling in Promovolve uses a Beta-Bernoulli conjugate model to represent uncertainty about each candidate’s click-through rate (CTR).

The Model

Each ad impression is a Bernoulli trial: click (success) or no click (failure). The unknown CTR p is represented by a Beta distribution.

Conjugacy

The Beta distribution is the conjugate prior for the Bernoulli likelihood:

Prior:      Beta(α, β)
Likelihood: Bernoulli(p)
Posterior:  Beta(α + clicks, β + non_clicks)

Updates are trivial — just add counts. No MCMC, no variational inference, no gradient descent. Critical for serve-time performance.

Prior

Promovolve uses Beta(1, 1) — uniform over [0, 1]:

Beta(1, 1) = Uniform(0, 1)
  Mean: 0.5
  Variance: 0.083
  → Maximum uncertainty

Posterior from Time-Bucketed Stats

The posterior uses aggregated statistics from the 60-minute rolling window of 1-minute buckets:

impressions = sum of all bucket impression counts
clicks = sum of all bucket click counts

Posterior: Beta(clicks + 1, impressions - clicks + 1)

Posterior Evolution

After 0 impressions:    Beta(1, 1)       mean=0.500  — wide, pure exploration
After 10 imp, 1 click:  Beta(2, 10)      mean=0.167  — starting to narrow
After 100 imp, 3 clk:   Beta(4, 98)      mean=0.039  — fairly confident
After 1000 imp, 30 clk: Beta(31, 971)    mean=0.031  — very confident

As data accumulates, the variance shrinks and samples cluster near the true CTR. This automatically reduces exploration for well-known creatives and maintains exploration for uncertain ones.

60-Minute Window Effect

Because stats are windowed to 60 minutes, the posterior resets as old data prunes. A creative that performed well an hour ago but has no recent data returns to higher uncertainty, enabling re-exploration. This is appropriate because CTR can vary by time of day, competing content, and audience composition.

Why Not Just Use Mean CTR?

Using the mean (greedy strategy) would never explore. Once a creative gets lucky with early clicks, it dominates forever. Thompson Sampling uses the full distribution — the variance captures uncertainty and drives exploration proportionally.

Scoring Formula

At serve time, Thompson Sampling selects which creative to show. The score combines two engagement posteriors with the advertiser bid:

engagement = sampledCTR + FoldWeight × sampledFold + newcomerBonus(impressions)
score      = engagement × CPM^α

Where:

  • sampledCTR: A random draw from the click-rate Beta posterior — how likely a reader is to click
  • sampledFold: A random draw from the fold-rate Beta posterior — how likely a reader is to bookmark (dog-ear) the creative for later
  • FoldWeight = 2.0: Folds are rarer than clicks but signal stronger intent, so they’re weighted twice as much per unit rate
  • newcomerBonus: An additive boost for creatives that haven’t yet built up their own posterior; decays linearly with impressions
  • CPM: The advertiser’s maximum bid per thousand impressions
  • α: The publisher’s bid weight — how much money matters vs quality

Why Two Posteriors?

A click is the cheapest unit of attention — fingers slip, headlines mislead, sometimes you click and immediately regret it. A fold (dog-ear bookmark) requires the reader to deliberately tap the corner of an expanded creative because they want to come back to it later. That’s a much stronger signal of intent.

Tracking both gives the auction a fuller picture: a creative with a 5% CTR and a 30% fold rate is signaling much more value to the publisher’s audience than a creative with the same CTR and a 1% fold rate. The combiner weights folds at 2× CTR’s contribution per unit rate, so a high fold rate moves the score noticeably even when click rates are similar.

Both posteriors live in the same CreativeStats bucket structure (1-minute buckets, 60-minute window) and update together on the same fold/click/impression beacons. See Beta-Bernoulli Model for the conjugate-prior math that lets them share the framework.

The Publisher’s Dial

The exponent α is the publisher’s single most important control. It determines who wins when quality and money disagree:

SettingαEffect
Discovery0.3Quality dominates. A $2 ad with great CTR beats a $5 ad with mediocre CTR. Grows the advertiser base.
Balanced0.5Equal weight. Score = CTR × √CPM. The default.
Revenue0.7Money dominates. Higher bids win more often. Maximizes short-term revenue.

A small publisher with few advertisers wants Discovery — keep everyone competitive, let quality creatives win, attract more advertisers. A high-traffic news site wants Revenue — extract maximum value from each impression.

Why Not log(1 + CPM)?

An earlier version used log(1 + CPM) to compress bid differences. This compressed too aggressively:

log:  $10 bid / $1 bid → only 3.5x score advantage
sqrt: $10 bid / $1 bid → 3.2x advantage
linear: $10 bid / $1 bid → 10x advantage

With log, a $5 bid barely beat a $1 bid. A small CTR advantage could overcome a 5x bid difference. This was too publisher-friendly — advertisers who bid more saw little benefit.

The configurable CPM^α lets the publisher choose their tradeoff. At α=0.5 (sqrt), a 10x bid advantage becomes a 3.2x score advantage — meaningful but not overwhelming. Quality still matters.

Numerical Example

Two campaigns compete for a travel article slot. The publisher uses Balanced (α=0.5):

Takeshi’s RyokanJR Rail Pass
Max CPM$5.00$8.00
True CTR~4%~2%
True Fold rate~12%~3%
Sampled CTR0.0380.025
Sampled Fold rate0.1150.028
Engagement (CTR + 2×Fold)0.2680.081
CPM^0.52.242.83
Score0.6000.229

Takeshi’s ryokan wins decisively. The fold signal is doing most of the work — readers who saw both creatives bookmarked Takeshi’s nearly 4× more often, and FoldWeight = 2.0 projects that into a meaningful score gap. Without the fold posterior, the scores would have been much closer (0.085 vs 0.071, the original CTR-only example), and a small bid bump could have flipped the outcome.

This is the point of the fold signal: it lets the auction reward creatives that earn deeper engagement, not just curiosity clicks.

Quality-Adjusted Pricing

The winner doesn’t pay their max CPM. They pay the minimum bid that would still win given their CTR:

payment = (bestLoserScore / winnerCTR) ^ (1/α)

In the example above (Balanced, α=0.5):

  • Ryokan wins with score 0.085
  • JR’s losing score: 0.071
  • Ryokan pays: (0.071 / 0.038)² = $3.49

Takeshi bid $5.00 but pays $3.49. The quality discount saved 30%. This is the system rewarding good creative work — higher CTR means lower effective cost.

During exploration (cold start, warmup, impression share guarantee), the winner pays the publisher’s floor price. You shouldn’t pay market rates for an impression that was given to you for learning purposes.

Cold Start Variant

When a creative has zero impressions, neither posterior has data. The system substitutes priors so cold candidates still produce a meaningful score:

sampledCTR  = categoryScore + random(-0.15, +0.15)   // page-classifier prior
sampledFold = sampleBeta(1, 1)                        // uniform [0,1] cold prior
engagement  = sampledCTR + 2.0 × sampledFold + NewcomerBoost
score       = engagement × CPM^α

The ±0.15 CTR noise ensures cold candidates have variance for exploration. The fold-rate prior is Beta(1, 1) — uniform over [0, 1] — which gives proper Thompson exploration on the fold dimension instead of pinning it to zero (an earlier bug where cold creatives could never win against warm fold-rich ones).

The newcomer bonus is a UCB-style additive boost — full strength at impressions=0, decaying linearly to zero by the 50th impression. It guarantees newcomers get exposure even when warm creatives have built up confident posteriors. See Cold Start Strategies for the decay curve.

What the Advertiser Sees

The advertiser sees two CPM values on their dashboard:

  • Max CPM: what they bid ($5.00)
  • eCPM: what they actually pay on average (e.g., $3.41)

The gap between these is the quality discount. An advertiser with great creatives (high CTR) pays significantly less than their bid. An advertiser with poor creatives pays closer to their max. The system aligns incentives: make better ads, pay less.

Cold Start Strategies

New candidates enter the system with zero impressions. Promovolve uses three structural strategies depending on the state of the candidate pool — plus a continuous newcomer bonus that boosts under-sampled creatives across all phases.

Strategy 1: Full Cold Start

Condition: All candidates in the slot have 0 impressions.

Algorithm: Use categoryScore from the auction phase as a prior, with noise:

sampledCTR = categoryScore + random(-0.1, +0.1)
score = sampledCTR × CPM^α

The categoryScore = classifierConfidence × rankerWeight provides a signal from the TaxonomyRankerEntity. The ±0.1 noise ensures different candidates are selected across requests even when they have identical category scores.

Strategy 2: Warmup Phase

Condition: All candidates have fewer than 10 impressions (WarmupImpressions = 10).

Algorithm: Round-robin — always select the candidate with the fewest impressions:

select = argmin(candidate.impressions)

No Thompson Sampling runs during warmup. This guarantees every candidate gets at least 10 impressions before exploitation begins.

Why 10? At 10 impressions with a typical 2-5% CTR, the expected number of clicks is 0-1. The Beta distribution Beta(1, 10) or Beta(2, 9) has sufficient shape to distinguish different CTRs but is still wide enough for continued exploration after warmup ends.

Strategy 3: Partial Cold Start

Condition: Some candidates have data (≥ 10 impressions) and some are new (0 impressions).

Algorithm: Epsilon-greedy with ExplorationRate = 0.30:

if random() < 0.30:
    select randomly from cold candidates (impressions == 0)
else:
    run Thompson Sampling on all candidates

The 30% rate is aggressive by design — new candidates need data quickly. Once they accumulate impressions, Thompson Sampling’s Beta posterior handles exploration naturally.

Note: When Thompson Sampling runs in the else branch, it runs on all candidates including cold ones. Cold candidates use categoryScore + random(-0.15, +0.15) as their sampled CTR, plus a fold rate sampled from Beta(1, 1) (uniform [0, 1]) so cold creatives have a real fold component instead of a hardcoded zero. Without the fold prior, a cold creative’s engagement = sampledCTR + 0 could never beat a warm fold-rich one’s sampledCTR + 2.0 × foldRate — the dominant exploration mechanism would silently fail. They still benefit from the Newcomer Bonus on top.

Newcomer Bonus: Decaying Additive Boost

The three strategies above are structural — they redirect selection on specific conditions. Cutting across all of them is a continuous additive bonus applied during the score combiner that tilts the auction toward creatives with few impressions:

engagement = sampledCTR + FoldWeight × sampledFold + newcomerBonus(impressions)

newcomerBonus(n) = max(0, NewcomerBoost × (1 - n / NewcomerDecayImpressions))

With NewcomerBoost = 0.5 and NewcomerDecayImpressions = 50, the curve is:

ImpressionsBonusEffect
0+0.50Brand new — full boost
10+0.40Past forced warmup, still strongly favored
25+0.25Half-faded
500.00Bonus exhausted — competing on its own posteriors
100+0.00No boost; warm creative

This is a UCB (Upper Confidence Bound) flavored adjustment grafted onto Thompson Sampling. Pure TS already over-prefers high-variance candidates, but in practice the variance gain from a small impression count isn’t always enough to outpace a confident warm creative with established stats. The decaying bonus closes that gap explicitly: brand new creatives get a guaranteed exploration runway, and the boost fades smoothly so the system isn’t permanently subsidizing newcomers that turned out to be poor performers.

The bonus continues past WarmupImpressions = 10 (where the forced round-robin ends) so the creative gets help during the early exploitation period when its posterior is wide but no longer being protected by the warmup phase.

Strategy Selection Flow

Are all candidates at 0 impressions?
  └── Yes → Full Cold Start (categoryScore ± 0.1 noise)
  └── No  → Are all candidates under 10 impressions?
              └── Yes → Warmup (round-robin by fewest impressions)
              └── No  → Are some candidates at 0 impressions?
                          └── Yes → Partial Cold Start (30% epsilon-greedy)
                          └── No  → Standard Thompson Sampling

Key Constants

ConstantValueLocation
ExplorationRate0.30ThompsonSampling.scala
WarmupImpressions10ThompsonSampling.scala
NewcomerBoost0.5ThompsonSampling.scala
NewcomerDecayImpressions50ThompsonSampling.scala
FoldWeight2.0ThompsonSampling.scala
Cold CTR noise range±0.15ThompsonSampling.scala
Cold fold priorBeta(1, 1)ThompsonSampling.scala
Full cold CTR noise range±0.1ThompsonSampling.scala

Beta Distribution Sampling

Thompson Sampling requires drawing random samples from Beta distributions on every serve request. The implementation uses the Marsaglia-Tsang method for Gamma variates, then converts to Beta.

Beta from Gamma

Beta(α, β) = X / (X + Y)
where X ~ Gamma(α, 1) and Y ~ Gamma(β, 1)

Gamma Sampling: Marsaglia-Tsang

Case 1: shape ≥ 1 (Rejection Sampling)

d = shape - 1/3
c = 1 / sqrt(9 × d)

repeat:
    x ~ Normal(0, 1)
    v = (1 + c × x)³
    u ~ Uniform(0, 1)
until v > 0 AND log(u) < 0.5 × x² + d - d × v + d × log(v)

return d × v

Acceptance rate is ~98% for shape ≥ 1, making this efficient for production use.

Case 2: shape < 1 (Recursion + Power Trick)

Gamma(shape, 1) = Gamma(shape + 1, 1) × U^(1/shape)
where U ~ Uniform(0, 1)

Reduces to Case 1 since shape + 1 ≥ 1.

Why Marsaglia-Tsang?

AlternativeProblem
Inverse CDFBeta quantile function requires regularized incomplete beta — expensive
Pre-computed tablesUnbounded (α, β) pairs as stats change per impression
Normal approximationBreaks for small α + β — exactly the exploration-critical case

Numerical Stability

The implementation handles edge cases:

  • α or β very small (< 0.01): clamped to avoid division by zero in power trick
  • Very large shape: Marsaglia-Tsang is naturally stable
  • Sample = 0 or 1: clamped to [ε, 1-ε] to avoid log(0) in downstream scoring

Performance

OperationCost
One Beta sample~3 uniform random draws + arithmetic
Per-candidate scoring1 Beta sample + 1 log + 1 multiply
Full selection (K=3)3 Beta samples + argmax

Total overhead: negligible compared to the DData lookup. The sampling is synchronous and runs on the Pekko dispatcher thread handling the serve request.

Fair Candidate Selection

The serve-time selection pipeline applies multiple filters before Thompson Sampling runs, ensuring only eligible candidates are considered.

Complete Selection Pipeline

From the AdServer source code, the exact order of operations:

1. Lookup ServeView from DData
   Key: "siteId|slotId"
   → Vector[CandidateView]

2. Content Recency Filter
   Keep if: (now - classifiedAtMs) ≤ contentRecencyWindowMs (48h)

3. Frequency Cap Check (if userId provided AND any caps exist)
   → Group candidates by advertiserId
   → Query AdvertiserEntity for user impression counts (100ms timeout)
   → Filter: keep if impressions < frequencyCap
   → Fail open on timeout (include all)

4. Rate Tracking (synchronous)
   → TrafficObserver.recordRequest(nowMs)
   → Update EMA-smoothed request rate (1s window, α=0.3)
   → BEFORE any async operations

5. Pacing Gate (BEFORE Thompson Sampling)
   → Fetch CachedSpendInfo for all participating campaigns
   → Compute aggregate PacingContext
   → PacingStrategy.throttleProbability(ctx) → [0.0, 0.99]
   → if random() < throttleProb: return NoCandidates (204)
   → Pacing gates VOLUME, not CHOICE

6. Thompson Sampling Selection
   → Cold start strategy selection (full cold / warmup / partial / standard)
   → Score: sampledCTR × CPM^α
   → Select argmax

7. Budget Reservation
   → CampaignEntity.Reserve(spend estimate)
   → AdvertiserEntity.GetBudgetStatus()
   → On failure: loop to next-best Thompson score candidate
   → All exhausted: return NoCandidates

Why Pacing Before Thompson Sampling?

If pacing ran after TS:

  • TS picks a creative → pacing throttles → wasted exploration (we learned nothing)
  • TS would consistently select a high-CTR creative that gets throttled, biasing future selection

With pacing before TS:

  • Throttle decision is independent of creative choice
  • When a request passes the gate, TS explores the full eligible set
  • Every Thompson Sampling decision contributes useful data

Campaign Mix Change Detection

When the set of participating campaigns changes between requests:

if lastCampaignSet.nonEmpty && currentCampaignSet != lastCampaignSet:
    log campaign mix changed (added/removed)
    pacingStrategy.reset()  // Don't let PI compensate for mix changes

This prevents the PI controller from making corrections based on stale campaign data.

Orphaned Creative Preservation

When new auction results arrive, creatives from the previous auction that aren’t in the new set are preserved as “orphaned”:

orphanedCreatives = existingCandidates.filterNot(c =>
    newAuctionCreativeIds.contains(c.creativeId)
)
mergedCandidates = (newCandidates ++ orphanedCreatives).distinctBy(_.creativeId)

This ensures multi-campaign diversity survives across auction cycles and approval status is preserved.

Per-Campaign Diversity

Promovolve ensures diversity through two mechanisms: the auction-time fair selection algorithm and serve-time aggregate pacing.

Auction-Time Diversity

The candidate shortlisting algorithm (in AuctioneerEntity) guarantees per-campaign representation:

1. Group candidates by campaign
2. Pick best creative per campaign (by CPM)
3. If #campaigns ≥ #slots:
     Take top campaigns by CPM → one creative each
4. Else:
     Each campaign gets 1 slot (guaranteed)
     Fill remaining slots with next-best creatives

This means 3 campaigns competing for 3 slots each get exactly 1 slot, rather than a single high-CPM campaign filling all 3.

Serve-Time Aggregate Pacing

The pacing gate operates on aggregate campaign metrics, not per-campaign:

PacingContext(
  dailyBudget = sum of all participating campaign budgets,
  todaySpend = sum of all campaign spends (including pending),
  avgCpm = CPM-weighted average across campaigns,
  competingCampaigns = count of campaigns with budget remaining,
  ...
)

Why Aggregate?

Per-campaign pacing would allow a high-budget campaign to crowd out a low-budget one:

  • Campaign A ($1000/day): barely paced, always serving
  • Campaign B ($10/day): heavily paced, rarely serving

Aggregate pacing asks: “Given the total budget of all campaigns here, is the combined spend rate appropriate?” This naturally balances delivery.

Thompson Sampling as Natural Diversifier

Thompson Sampling itself provides diversity without explicit constraints:

  • Each creative has its own Beta(clicks+1, impressions-clicks+1) posterior
  • Sampling from Beta naturally introduces variance — even a dominant creative samples low sometimes
  • New creatives have wide distributions → high variance → get explored
  • Per-creative independence means every creative gets its own learning trajectory

Ad Product Blocklist

Publishers can configure per-site ad product category blocklists:

adProductBlocklist: Set[AdProductCategoryId]

Distributed via DData (AdProductBlocklistKey), this filter runs at auction time to exclude entire categories of ads (e.g., gambling, alcohol) from the publisher’s inventory.

Creative Deduplication

When merging new auction results with existing candidates:

mergedViews = (newCandidates ++ orphanedCreatives).distinctBy(_.creativeId)

This prevents the same creative from appearing multiple times, which would bias Thompson Sampling.

Frequency Capping

Frequency capping limits how many times a single user sees ads from the same advertiser, preventing ad fatigue.

How It Works

Per-User, Per-Creative Caps

Each campaign can specify a frequencyCap: Option[Int] — the maximum number of impressions per user for creatives from that campaign.

Check Process

At serve time, before the pacing gate:

// 1. Filter candidates with frequency caps
val cappedCandidates = candidates.filter(_.frequencyCap.isDefined)

// 2. Group by advertiser
val byAdvertiser = cappedCandidates.groupBy(_.advertiserId)

// 3. Query each AdvertiserEntity for user impression counts
//    Timeout: 100ms, fail-open

// 4. Filter
filtered = candidates.filter { c =>
  c.frequencyCap match {
    case None      => true  // No cap, always eligible
    case Some(cap) =>
      val impressions = impressionCountsMap.getOrElse(c.creativeId, 0)
      impressions < cap
  }
}

Fail-Open Semantics

If the AdvertiserEntity doesn’t respond within 100ms:

On timeout → include all candidates from that advertiser

Why fail-open? Frequency capping is a quality optimization. The alternative (fail-closed) would mean network issues cause no ads to show. It’s better to occasionally over-serve than to block serving entirely.

Pipeline Position

Frequency capping runs after content recency but before the pacing gate and Thompson Sampling:

Content recency → Frequency cap → Rate tracking → Pacing gate → Thompson Sampling

Running before TS ensures:

  • TS never wastes exploration on capped candidates
  • The filtered pool may be smaller but TS works correctly with any size ≥ 1
  • If all candidates are capped, no ad is shown (NoCandidates)

Interaction with Pacing

Frequency capping and pacing are independent filters. A candidate must pass both:

Candidates → Frequency Filter → Pacing Gate → Thompson Sampling

Running frequency cap first reduces the number of candidates the pacing gate needs to evaluate.

Pin-Honoring at Serve Time

The dog-ear gives readers a way to bookmark an ad they want to come back to. The bookmark lives in the reader’s own browser; this chapter is about what the server does when the bookmark comes back.

For the reader-side protocol — FoldToken, IndexedDB pin storage, the /v1/dogear-event endpoint — see The Dog-Ear. This chapter assumes the bootstrap has already read its IDB and forwarded the pin to the server as part of the batch serve request.

Where the pin slots into the pipeline

The serve pipeline runs in this order:

1. ServeIndex lookup          → fetch cached candidates (local DData replica)
2. Content recency             → drop if classification is older than 48h
3. Frequency cap               → drop creatives this user has seen too often
4. Pacing gate                 → probabilistic throttle by aggregate budget
5. Site-wide pin exclusion     → off-page pins remove campaigns from the pool
6. Pin-honor (per-slot)        → if the slot carries a pin and the pinned
                                  creative is still in the pool, bypass the
                                  rest of this list and serve the pin
7. Quality-adjusted auction    → score, pick, clear at second-price
8. Budget reservation          → reserve at clearing price

The pin-honor check is per-slot, inside batchReserveWithRetry’s assignment loop. Pinned slots are sorted to the front of the assignment so the pinned creative’s campaign locks the page-cap state before any non-pinned slot picks. Larger slots within each group still come first — pinned big-slot before pinned small-slot, then unpinned big-slot, then unpinned small-slot.

A pinned slot whose creative is found in the pool never sees the pacing throttle, the frequency cap, or the auction. The reader chose this creative; the system honors that choice rather than relitigating it through gates designed for un-bookmarked ads.

The honor path

When slot.pin is set and the pool contains a CandidateView with that creativeId:

Protocol.BatchSlotOutcome(
  slotId        = slot.slotId,
  winner        = Some(c),
  clearingPrice = CPM.zero,                              // free re-encounter
  requestId     = java.util.UUID.randomUUID().toString,
  dogear        = Some(DogearOutcome(honored = true)),
)

Three things happen — and three things explicitly don’t:

  • Reservation skipped. The pinned candidate is not passed to batchReserveOne. No round-trip to CampaignEntity or AdvertiserEntity for budget gating.
  • Clearing price is zero. Folds are free engagement signals (see Quality-Adjusted Pricing). Pin re-encounters extend that — the reader’s bookmark gets honored at no charge to the advertiser.
  • Pacing throttle skipped. The pacing gate runs before pin-honor in the request lifecycle, but for the pinned slot specifically, no throttling decision applies — the slot bypasses budget reservation entirely, so pacing has nothing to gate.

The slot does still consume a campaign and a creative in the page-cap state:

used = used + c.campaignId.value
usedCreatives = usedCreatives + c.creativeId

This is deliberate. Without the consume, a non-pinned slot on the same page could pick another creative from the bookmarked campaign and you’d see two different ads for the same advertiser side-by-side. The pin is a “save for later” gesture; surfacing other creatives from that advertiser would feel like recommendation-engine stalking.

Fallthrough: when the pin can’t be honored

A pin can fail for two reasons:

Transient miss. The pinned creative is still approved on this site, but it isn’t in this particular batch’s pool — maybe the auction’s eligibility filters happened to drop it (size mismatch with the slot, recency window, etc.). The reader’s bookmark is still valid; the client should keep the pin and re-honor on the next page.

Truly removed. The pinned creative has been revoked from approval — campaign paused, creative unassigned, advertiser removed. The bookmark is dead; the client should clean up its IDB entry.

The server distinguishes these with the isApproved predicate, which is supplied by the live AdServer actor from its persistedApprovedIds set:

def dogearFallthrough(
    slot: BatchSlotSpec,
    isApproved: CreativeId => Boolean,
): Option[DogearOutcome] =
  slot.pin match {
    case Some(cid) if !isApproved(cid) =>
      Some(DogearOutcome(honored = false, reason = Some("creative_removed")))
    case _ =>
      None
  }

Two outcome shapes the bootstrap acts on:

Server responseBootstrap action
dogear = NoneNo pin or transient miss — keep the IDB entry, retry next page
dogear = Some(DogearOutcome(honored=true))Pin served — render the bookmarked creative
dogear = Some(DogearOutcome(honored=false, reason="creative_removed"))Bookmark is dead — delete the IDB entry

The bootstrap’s IDB cleanup happens in bootstrap.ts’s displayImpl: when the response carries creative_removed, the slot’s pin row is deleted before the next render.

The isApproved gate matters

The gate distinguishes “creative not in pool right now” from “creative is gone for good.” Treating both as “remove the pin” would make pins unreliable — a creative that happened to fall out of one auction’s eligibility would lose every reader’s bookmark. Treating both as “keep the pin” would leave dead bookmarks in IDB forever.

The gate’s source of truth is persistedApprovedIds — the AdServer’s view of what’s currently approved on this site. Pause a campaign, that campaign’s creative IDs are removed from persistedApprovedIds immediately (see the CampaignPaused handler at line 1107 of AdServer.scala); the next pin attempt for one of those creatives gets creative_removed and the IDB cleans up.

Site-wide pin exclusion

A pin on slot S₁ of page P is also a signal about other slots on page P, and other pages on the site:

Same-page (page-cap consume). Already covered: the pinned creative’s campaign goes into used so other slots on the same page can’t pick another creative from that advertiser.

Off-page (site-wide block). When the bootstrap submits a batch request, it sends along its full set of pins — including pins on slots that aren’t on this page. The server resolves those off-page pins through creativeRepo.get() to find their campaignId, then passes both as exclusions to the batch:

BatchSelect(
  ...
  excludedCreatives = offPagePinCreatives,   // pinned somewhere else on this site
  excludedCampaigns = offPagePinCampaigns,   // their advertisers
)

excludedCreatives and excludedCampaigns seed the usedCreatives and used sets at the start of batchReserveWithRetry. So a creative the reader pinned on a different page can’t appear as a normal-auction winner on this page, and neither can any other creative from that advertiser.

The reasoning is the same as the same-page case, scaled up: the dog-ear is a save-for-later, and showing other ads from that advertiser elsewhere on the site would dilute the bookmark’s value.

Tests as contract

AdServerPinHonorSpec pins the pin-honor semantics in eight cases:

  1. Honor a pin when the creative is in the pool, bypassing reservation.
  2. Fall through to auction with creative_removed when the pinned creative is no longer approved.
  3. Leave dogear = None on a slot that carried no pin.
  4. Honor a pin even when the pinned creative’s CPM is below the site floor (pins aren’t auction wins).
  5. Honor a pin even when the pinned creative’s campaign is in pageBlocked (soft cap doesn’t apply to explicit reader choices).
  6. Lock the pinned creative’s campaign so other slots can’t double up.
  7. Lock the pinned creativeId so it can’t be re-served at a different size on another slot.
  8. Honor multiple pins independently in a single batch.

These tests are the authoritative description of what pin-honoring does. Future refactors can’t silently regress without breaking them.

Source of truth

  • modules/core/src/main/scala/promovolve/publisher/delivery/AdServer.scalabatchReserveWithRetry (per-slot pin-honor inside the assignment loop), dogearFallthrough (the isApproved gate)
  • modules/core/src/main/scala/promovolve/publisher/delivery/Protocol.scalaBatchSlotSpec.pin, DogearOutcome
  • modules/api/src/main/scala/promovolve/api/ServeRoutes.scala — off-page pin resolution to excludedCreatives / excludedCampaigns
  • modules/core/src/test/scala/promovolve/publisher/delivery/AdServerPinHonorSpec.scala — semantics frozen in tests
  • platform/banner-bootstrap/src/bootstrap.ts — client-side IDB cleanup on creative_removed

Pacing Overview

Budget pacing ensures campaigns spend their daily budgets smoothly throughout the day. Promovolve uses a self-tuning PI control loop with traffic shape awareness, leaky integrator anti-windup, oscillation detection, and cross-day learning.

Why Pacing Matters

Without pacing, a campaign with a $100 daily budget and $5 CPM would exhaust after 20,000 impressions. If those arrive in the morning peak, the campaign goes dark for the remaining day.

Why PI Control Works Here

PI (Proportional-Integral) control is a technique from industrial process control — thermostats, motor speed regulation, chemical plant flow rates. It works well when the controller can observe the system’s output and adjust a single input to drive it toward a target.

Budget pacing in Promovolve fits this model because it’s a closed system. The platform controls both sides of the equation:

  • The input: the throttle probability (what fraction of ad requests to serve)
  • The output: the spend rate (how fast budget is consumed)
  • The target: even delivery across the day (spend rate = budget / time remaining)

The controller observes the spend rate, compares it to the target, and adjusts the throttle. Overspending? Throttle up (skip more requests). Underspending? Throttle down (serve more). The feedback loop is tight and the response is predictable.

This wouldn’t work in the traditional programmatic stack. In RTB, the publisher submits impressions to an exchange and has no control over whether a campaign wins any given auction — that depends on competing bids from unknown DSPs. The campaign’s delivery rate is a function of market dynamics the pacing controller can’t observe or influence. You can adjust your bid, but you can’t control the outcome.

In Promovolve, there’s no external auction to compete in at serve time. The candidates are already cached. The pacing gate is a simple yes/no decision on each request, and the controller has full authority over that decision. This makes the system controllable in the control-theory sense — the input (throttle) directly determines the output (delivery rate), with no unobservable external disturbances.

That’s why a PI controller — a well-understood, stable, analytically tractable technique — works for a problem that the traditional ad tech industry solves with heuristic rules and hope.

Promovolve’s Approach

graph LR
    TO["Traffic Observer<br/>(EMA rate, 1s)"] --> PI["PI Control<br/>(self-tuning)"]
    PI --> Throttle["Throttle Prob<br/>[0.0, 0.99]"]
    PI --> Shape["Traffic Shape<br/>(weekday/weekend<br/>24h buckets)"]
    Shape --> TO
    Throttle --> Bernoulli["Bernoulli<br/>Serve or Skip"]

Key Components

  1. Rate Tracking (EMA): Synchronous, 1-second window, α=0.3
  2. PI Control Loop: Self-tuning gains, asymmetric response, leaky integrator
  3. Traffic Shape Learning: Separate weekday/weekend 24-hour profiles
  4. Grace Periods: Startup protection with MaxThrottleProb (0.99)

Pipeline Position

Pacing operates as a volume gate before Thompson Sampling:

Content recency → Frequency cap → Rate tracking → Pacing gate → Thompson Sampling

The pacing gate makes a Bernoulli decision: if random() < throttleProbability → skip (204). This gates volume, not choice — Thompson Sampling only runs for requests that pass the gate.

Key Constants (from AdaptivePacing.scala)

ConstantValue
MaxThrottleProb0.99 (1.0 reserved for hard-stop)
DefaultKp0.5
DefaultKi0.3
BaseOverpaceGainMultiplier2.0
IntegralDecayFactor0.995 (leaky integrator)
SpendRatioSmoothingAlpha0.3
DefaultAvgCpm$5.00

Rate Tracking (EMA)

Accurate rate measurement is the foundation of the pacing system. Promovolve uses a synchronous Exponential Moving Average (EMA) with a 1-second sliding window.

TrafficObserver

From pacing/TrafficObserver.scala:

class TrafficObserver(
  rateWindowMs: Long = 1000,    // 1-second window
  rateEmaAlpha: Double = 0.3     // EMA smoothing factor
)

Recording (Synchronous)

Called on every Select request, before any async operations:

recordRequest(nowMs):
  if windowStartMs == 0: windowStartMs = nowMs

  requestsInWindow += 1
  windowElapsed = nowMs - windowStartMs

  if windowElapsed >= rateWindowMs:   // window closed
    windowSec = windowElapsed / 1000.0
    instantRate = requestsInWindow / windowSec
    smoothedRate = α × instantRate + (1 - α) × smoothedRate

    windowStartMs = nowMs
    requestsInWindow = 0

  return smoothedRate

EMA Behavior

With α = 0.3:

Window 1: instant=100, smoothed = 0.3×100 + 0.7×0   = 30
Window 2: instant=120, smoothed = 0.3×120 + 0.7×30  = 57
Window 3: instant=110, smoothed = 0.3×110 + 0.7×57  = 73
Window 4: instant=105, smoothed = 0.3×105 + 0.7×73  = 83
Window 5: instant=100, smoothed = 0.3×100 + 0.7×83  = 88

Converges within ~5 windows. Spikes are dampened:

Window 6: instant=500, smoothed = 0.3×500 + 0.7×88  = 212  (spike dampened)
Window 7: instant=100, smoothed = 0.3×100 + 0.7×212 = 178  (recovering)

Why Synchronous?

The rate tracking call is synchronous and runs on the same thread handling the serve request. This ensures:

  • Every request is counted exactly once
  • No race conditions from async updates
  • Rate is always current when the pacing gate runs

Stabilization

The grace period requires EmaStabilizationWindows = 3 windows of data before the EMA is considered stable. During these initial windows, the grace period remains active to prevent PI corrections based on noisy rate estimates.

Usage in PI Control

The smoothed rate feeds into the base throttle calculation:

baseTargetImpsPerSec = (dailyBudget / dayDurationSeconds) / (avgCpm / 1000.0)
baseThrottle = 1.0 - (baseTargetImpsPerSec / requestRate)

Where requestRate is the EMA-smoothed rate from TrafficObserver.

PI Control Loop

Promovolve uses a self-tuning Proportional-Integral (PI) controller with adaptive gains, asymmetric response, a leaky integrator, and oscillation detection.

Core Algorithm (from AdaptivePacing.scala)

// 1. Hard stops
if remainingBudget ≤ 0: return 1.0
if remainingHours ≤ 0: return 1.0

// 2. Base throttle from target impressions per second
baseTargetImpsPerSec = (dailyBudget / dayDurationSec) / (avgCpm / 1000.0)

// 3. Apply traffic shape multiplier (if available)
if trafficShape exists:
    shapeMultiplier = trafficShape.relativeVolumeWithFeedforward(elapsed, feedforwardWindow)
    baseTargetImpsPerSec *= shapeMultiplier

baseThrottle = 1.0 - (baseTargetImpsPerSec / requestRate)

// 4. Compute error
error = 1.0 - spendRatio
// positive → under-spending, negative → over-spending

// 5. Asymmetric gains
if error < 0 (over-pacing):
    effectiveKp = kp × overpaceGainMultiplier    // default: kp × 2.0
    effectiveKi = ki × overpaceGainMultiplier
else:
    effectiveKp = kp
    effectiveKi = ki

// 6. Leaky integrator (anti-windup)
integralError *= IntegralDecayFactor    // 0.995 per update
integralError += error × dt
integralError = clamp(integralError, -1.0, 1.0)

// 7. PI adjustment
adjustment = effectiveKp × error + effectiveKi × integralError

// 8. Final throttle
finalThrottle = clamp(baseThrottle - adjustment, 0.0, MaxThrottleProb)
// MaxThrottleProb = 0.99 (1.0 reserved for hard-stop)

Spend Ratio Smoothing

Raw spend ratio is noisy. The system applies EMA smoothing:

smoothedSpendRatio = α × rawSpendRatio + (1 - α) × previousSmoothed

Default SpendRatioSmoothingAlpha = 0.3, but the alpha itself is self-tuned:

  • If oscillation detected (stddev > 0.08): decrease alpha toward MinSmoothingAlpha (0.1) — more dampening
  • If stable (stddev < 0.04): increase alpha toward MaxSmoothingAlpha (0.5) — more responsive

Self-Tuning Overpace Multiplier

The asymmetric gain multiplier is not fixed — it adapts over time:

Every 20 samples (and at least 500ms apart):
  if persistent overspend (avg spendRatio > 1.05):
      overpaceMultiplier *= OverspendBoostFactor (1.15)
      capped at MaxOverpaceGainMultiplier (5.0)
  elif well-paced (avg spendRatio < 1.02):
      overpaceMultiplier *= WellPacedDecayFactor (0.95)
      floored at MinOverpaceGainMultiplier (1.5)

This means the system becomes progressively more aggressive at correcting overspend if it keeps recurring, and relaxes when pacing is good.

Adaptive Gains by Traffic Volatility

PI gains scale with the coefficient of variation (CV) of request rates:

Volatility (CV)KpKiBehavior
0.0 (flat)0.30.2Gentle corrections for uniform traffic
0.5 (typical)0.50.3Moderate response
1.0+ (spiky)1.00.6Aggressive corrections for bursty traffic

Gains are linearly interpolated between these points based on the observed CV from the TrafficShapeTracker.

Leaky Integrator

The integral term decays by IntegralDecayFactor = 0.995 on every update. This prevents windup — where a prolonged error accumulates a large integral that then overshoots when conditions change.

The integral is also hard-clamped to [-1.0, 1.0] as a safety bound.

Cross-Day Learning

At day rollover, the system checks if the budget was exhausted too early:

prepareForRollover(budgetExhausted, remainingFraction):
  if budgetExhausted && remainingFraction > EarlyExhaustionThreshold (0.05):
      overpaceMultiplier *= (1.0 + remainingFraction)
      // If exhausted with 30% of day remaining → boost by 1.3x

This carries forward the lesson: “I should have been more conservative” into the next day’s pacing, even though the PI state itself resets.

Traffic Shape Learning

Web traffic follows daily patterns. Promovolve learns these patterns separately for weekdays and weekends using a TrafficShapeTracker with 24 hourly buckets.

TrafficShapeTracker (from source)

class TrafficShapeTracker(
  bucketCount: Int = 24,        // hourly buckets
  alpha: Double = 0.1,          // EMA learning rate
  interpolateVolumes: Boolean = false  // sharp vs smooth peaks
)

Separate Weekday/Weekend Profiles

private val weekdayShape: Array[Double] = Array.fill(24)(1.0)
private val weekendShape: Array[Double] = Array.fill(24)(1.0)
private val todayCount: Array[Long] = Array.fill(24)(0L)  // reset daily

The active shape is selected via setDayType(isWeekend: Boolean) at the start of each day.

Recording & Learning

Per-Request Recording

recordRequest(bucket, time):
    todayCount[bucket] += 1

On Bucket Boundary Change

When traffic moves to a new hour:

observation = requestsInBucket / max(1.0, emaBucketRequests)
shape[bucket] = α × observation + (1 - α) × shape[bucket]
emaBucketRequests = α × requestsInBucket + (1 - α) × emaBucketRequests

Day Rollover Blending

At end of day:

rolloverDay(dayAlpha = 0.2):
    todayNormalized[i] = todayCount[i] / avgCount
    shape[i] = 0.2 × todayNormalized[i] + 0.8 × shape[i]
    reset todayCount

The 0.2 blend rate means about 5 days of data to significantly influence the profile.

CDF for Expected Spend

The traffic shape provides a cumulative distribution function that replaces the linear time fraction in expected spend calculations:

cumulativeFractionAtTime(elapsedSeconds):
    bucket = floor(elapsedSeconds / bucketDurationSec)
    fractionIntoBucket = (elapsedSeconds % bucketDurationSec) / bucketDurationSec

    prevCumulative = sum(shape[0..bucket-1])
    currentContribution = shape[bucket] × fractionIntoBucket

    return (prevCumulative + currentContribution) / sum(all buckets)

Without traffic shape: expectedSpendFraction = elapsedTime / totalTime (linear) With traffic shape: expectedSpendFraction = cumulativeFractionAtTime(elapsed) (shaped)

Relative Volume (for Base Target)

The base target impressions-per-second is scaled by the current hour’s relative volume:

relativeVolumeWithFeedforward(elapsedSeconds, feedforwardWindow):
    bucket = current hour
    currentVol = shape[bucket]
    nextVol = shape[(bucket + 1) % 24]

    if feedforwardWindow > 0 AND near end of bucket:
        // Smooth transition using ease-in-out curve
        blendFactor = position within feedforward window [0, 1]
        smoothBlend = blendFactor² × (3 - 2 × blendFactor)
        effectiveVol = currentVol + smoothBlend × (nextVol - currentVol)
    else:
        effectiveVol = currentVol

    avgVol = sum(all buckets) / 24
    return effectiveVol / avgVol

The feedforward window (default: 0.0 = disabled) allows the system to anticipate the next hour’s traffic pattern and begin adjusting before the bucket boundary.

Volatility Measurement

The coefficient of variation (CV = stddev / mean) of the shape buckets is used to auto-tune PI gains:

  • Low CV → uniform traffic → gentle PI gains
  • High CV → spiky traffic → aggressive PI gains

Site-Level Configuration

Per-site traffic shapes can be pre-configured via PacingConfig:

PacingConfig(
  weekdayShapeVolumes: Option[Vector[Double]],  // 24 hourly values
  weekendShapeVolumes: Option[Vector[Double]],
  dayDurationSeconds: Int = 86400,
  warmupMode: Boolean = false
)

When warmupMode = true, the system records traffic patterns but does not serve ads — useful for learning the traffic shape of a new site before enabling monetization.

Grace Periods & Hybrid Modes

PI control needs stable input signals. During startup and after periods of inactivity, signals are noisy or meaningless. Grace periods protect the controller during these transients.

Grace Period: No Serving

During grace, the throttle probability is set to MaxThrottleProb = 0.99 — effectively no serving. This is the opposite of what you might expect: rather than serving freely during warmup, Promovolve suppresses serving until it has enough data to pace correctly.

Why suppress, not serve freely? Free serving during startup could exhaust the budget before the PI controller activates. The 0.99 throttle (not 1.0) lets through ~1% of requests as “sensors” to build rate data.

Grace Period Conditions

Grace is active until both conditions are met:

graceSeconds = max(MinGraceSeconds, DefaultGracePeriodFraction × dayDurationSeconds)
             = max(10.0, 0.01 × dayDurationSeconds)

graceRequests = MinGraceRequests = 10

Grace ends when:
  elapsedSeconds >= graceSeconds AND requestCount >= graceRequests

Additionally, the EMA needs EmaStabilizationWindows = 3 windows of data to stabilize.

Staleness Reset

If no requests arrive for a configurable period, grace re-enters:

staleThreshold = BaseStaleRateThresholdMs = 30,000ms
                 (scaled proportionally for simulated short days)
                 (min: MinStaleRateThresholdMs = 1,000ms)

if (nowMs - lastRequestMs) > staleThreshold:
    resetGracePeriod()

Why? After 30 seconds of silence, the EMA-smoothed rate has decayed and no longer represents current traffic. PI corrections with stale rate data would produce erratic throttle swings.

Grace Period Constants

ConstantValuePurpose
DefaultGracePeriodFraction0.01 (1% of day)Base grace duration
MinGraceSeconds10.0Minimum grace regardless of day length
MinGraceRequests10Minimum requests before PI activates
MaxGraceRequests50(Not currently used as lower bound)
EmaStabilizationWindows3EMA warmup windows
BaseStaleRateThresholdMs30,000Staleness detection
MinStaleRateThresholdMs1,000Min staleness for short simulated days
MaxThrottleProb0.99Throttle during grace

Grace Period Timeline

Time    Event                       Mode           Throttle
00:00   Site pacing starts          Grace          0.99 (~1% through)
00:05   5 requests arrived          Grace (count)  0.99
00:10   10s elapsed, 10+ requests   Grace (EMA)    0.99
00:13   3 EMA windows stable        PI active      Computed
...
01:30   No requests for 35s         Stale reset    0.99
01:31   Requests resume             Grace          0.99
01:41   Grace conditions met        PI active      Computed

Simulated Days

For testing and simulation, dayDurationSeconds can be set shorter than 86400 (e.g., 600 seconds for a 10-minute “day”). Grace periods, stale thresholds, and the RL observation interval all scale proportionally, ensuring the system behaves consistently regardless of the simulated time scale.

The Crawler

Promovolve’s auction is offline (see Periodic Batch Auction) — the system needs to know what each publisher page is about before any reader arrives. The crawler is what turns a publisher’s site into the page-classification table the auction reads from.

What the crawler does

Three jobs:

  1. Discover pages. Start from a publisher’s seed URL, follow same-host links breadth-first up to a configured depth.
  2. Render and extract. For each page, render with headless Chromium (so JS-built content is captured), pull text from configured target elements (e.g., article, main, .post-body), and detect ad slots in the DOM.
  3. Hand off to classification. The extracted text goes through the LLM classifier (Gemini Flash) which assigns IAB Content Taxonomy categories with confidence scores. The result is stored in AuctioneerEntity for the auction to read.

Output per page:

PageScrapedResult(
  url:    String,
  texts:  Seq[String],          // extracted text per target element
  links:  Seq[(href, anchor)],  // for breadth-first crawl
  slots:  Seq[DetectedSlot],    // ad slots found in DOM
  depth:  Int,
)

The classifier consumes texts, the crawl loop consumes links, and the publisher dashboard reads slots to surface “we found N ad slots on this page.”

Trigger model

Two ways a crawl starts:

  • Scheduled. Each SiteEntity carries a Quartz cron expression (crawlConfig.cronSchedule). The cluster’s Quartz scheduler fires the cron, sends StartCrawling to the site, and the entity requests a permit from CrawlScheduler before spawning.
  • On-demand. A publisher clicks “Recrawl now” in the dashboard, which sends StartCrawling directly. Same permit handshake.

The 48-hour recency window in AuctioneerEntity prunes stale classifications, so a cron schedule that runs every 12–24 hours keeps the classification table fresh without overwhelming the LLM budget. Most publishers use daily.

The browser context

Crawling uses a real headless Chromium (Playwright), not an HTTP fetch. Two reasons:

  • JS-rendered content. Many sites build their article body with JavaScript. An HTTP fetch returns the empty SPA shell and the classifier has nothing to work with.
  • Anti-bot resilience. Sites running Cloudflare Bot Management or Akamai will return blocked content (or empty bodies) to clients that look obviously automated.

The browser context is created by BrowserContextFactory, which injects a small stealth.js script before the target page’s scripts run. The script patches the most common headless-Chromium tells (navigator.webdriver, missing window.chrome, empty plugins, the notification-permission mismatch) so anti-bot fingerprinters see real-browser-shaped values. The same factory backs the LP-to-creative pipeline’s LPAnalyzer, so any improvements to stealth apply to both surfaces. Details live in modules/crawler/src/main/resources/stealth.js.

Each PlaywrightWorker rotates its browser context every 5 successful scrapes. Long-lived contexts accumulate cookies, local-storage state, and request fingerprints that can trip rate limits or freshness checks; rotation keeps each batch of pages presenting as a fresh browser session.

URL-block: the crawler can’t fire its own ads

This is the part of the crawler that’s specific to running on top of Promovolve. The crawler is fetching publisher pages — pages that already embed Promovolve’s own ad bootstrap. If we let the bootstrap run during the crawl, the crawler’s headless browser would fire impression beacons, click through to landing pages, and burn advertiser budget on serves that no human will ever see.

PlaywrightWorker intercepts every request and aborts ones that match Promovolve’s own delivery surface:

val isPromovolveAd =
  url.contains("promovolve-bootstrap") ||
  url.contains("/v1/serve/")           ||
  url.contains("/v1/imp")              ||
  url.contains("/v1/click")            ||
  url.contains("/v1/cta")              ||
  url.contains("/v1/dogear-event")

if (blockedTypes.contains(resourceType) || isPromovolveAd) route.abort()
else route.resume()

Same hook also blocks resource types that don’t help classification: image, font, media (and stylesheet when no click-selector is configured). Crawls run faster and cheaper without downloading every image and webfont on the page.

The publisher’s analytics, JS-rendered content, and any non-Promovolve scripts still load normally — only Promovolve’s own delivery path is short-circuited.

Failure modes

  • Bad HTTP status (≥ 400). Dropped, returns an empty PageScrapedResult. Classifier gets nothing for that URL; auction won’t include it.
  • Non-HTML content type. Logged and skipped. Crawl continues to the next URL.
  • Navigation timeout (15s default). Retried up to maxRetries; on final failure the URL is dropped with an empty result.
  • Browser crash / tab error. Caught, page closed, browser context kept; same retry logic.

The crawler is best-effort. A page that consistently fails to render isn’t a crisis — it just doesn’t appear in the classification table, which means it doesn’t appear in any auction. The auctioneer’s 48-hour recency window means failed crawls also age out automatically.

Where the CrawlScheduler fits in

A naive crawler with a singleton-per-site design would still produce a thundering herd on cluster restart: every SiteEntity activates simultaneously, every cron evaluates against “now” and fires immediately, every site’s PlaywrightWorker spawns its own browser context, and a hundred concurrent Chromium processes try to share the same machine.

The CrawlScheduler is a cluster-singleton concurrency-bounded queue between SiteEntity.StartCrawling and the actual PlaywrightWorker spawn. Sites request permits, the scheduler grants up to maxConcurrent at a time (default 8), excess sites wait. Restart no longer blows up the host. The next chapter covers how it works, including its safety net for sites that crash mid-crawl without releasing their permit.

Source of truth

  • modules/crawler/src/main/scala/promovolve/crawler/PlaywrightWorker.scala — page-scrape actor, URL-block, resource filtering, retry policy
  • modules/crawler/src/main/scala/promovolve/crawler/BrowserContextFactory.scala — context creation, stealth injection
  • modules/crawler/src/main/resources/stealth.js — the stealth patches
  • modules/core/src/main/scala/promovolve/publisher/SiteEntity.scalaStartCrawling handler, permit handshake with CrawlScheduler
  • modules/core/src/main/scala/promovolve/crawler/CrawlScheduler.scala — see next chapter

The Crawl Scheduler

The naive design — let every SiteEntity spawn its own PlaywrightWorker whenever it wants — works fine in dev with five sites. It blows up at a hundred. The crawl scheduler exists because of one specific failure mode: the post-restart thundering herd.

The thundering herd

Every JVM restart wipes AuctioneerEntity.lastPage (an in-memory cache, not persisted). The next time each site’s PeriodicReauction tick fires, the entity sees lastPage.isEmpty and concludes “I have no recent classifications, I should crawl.” With 100 sites, that’s 100 SiteEntity instances simultaneously deciding to crawl, simultaneously spawning a PlaywrightWorker, simultaneously launching a headless Chromium process.

A modern Mac can run maybe 8–12 Chromium processes before the OS starts killing things. A hundred is a guaranteed outage. Memory pressure spikes, the JVM gets OOM-killed, the cluster restarts, and the herd runs again.

The scheduler is a cluster singleton between SiteEntity and PlaywrightWorker that bounds concurrent crawls to maxConcurrent (default 8). Excess requests queue and run as slots free. Restart is no longer a self-inflicted denial-of-service.

The protocol

sealed trait Command
final case class RequestCrawlPermit(siteId: SiteId, ackRef: ActorRef[CrawlGranted.type]) extends Command
final case class ReleaseCrawlPermit(siteId: SiteId) extends Command
case object Stop extends Command

case object CrawlGranted    // reply

Three messages. Sites ask for a permit, the scheduler replies CrawlGranted either immediately (if a slot is free) or whenever one frees. Sites release the permit when the crawl finishes. That’s the whole external surface.

The handshake

SiteEntity.StartCrawling doesn’t spawn a worker directly any more. It pipes through the scheduler:

SiteEntity                       CrawlScheduler                 PlaywrightWorker
    │                                  │                              │
    │── RequestCrawlPermit ──────────▶│                              │
    │                                  │                              │
    │           (slot free)            │                              │
    │◀─── CrawlGranted ───────────────│                              │
    │                                  │                              │
    │── pipeToSelf(CrawlPermitGranted) │                              │
    │── spawn ────────────────────────────────────────────────────▶│
    │                                  │                              │── render…
    │                                  │                              │── extract…
    │◀─── CrawlerTerminated ────────────────────────────────────────│
    │── ReleaseCrawlPermit ──────────▶│                              │
    │                                  │── grant queued site ────▶ ...│

SiteEntity uses pipeToSelf(scheduler.request(siteId)) to hold the request as a Future until the grant arrives, then sends itself a CrawlPermitGranted message that triggers the actual PlaywrightWorker spawn. When the worker terminates (either successfully or via crash), the CrawlerTerminated watcher fires, which sends ReleaseCrawlPermit back to the scheduler so the next queued site can run.

State

The scheduler holds three mutable maps in memory — no persistence, no journal, no DB:

val running:      mutable.Map[SiteId, Instant]                              // siteId → grant time
val queue:        mutable.Queue[(SiteId, ActorRef[CrawlGranted.type])]      // FIFO waiters
val queuedSites:  mutable.Set[SiteId]                                        // dedup hint

running tracks who currently holds a permit and when they got it (timestamps drive the stale-release sweep, see below). queue is a FIFO of waiters; queuedSites is a side-set so duplicate-detection is O(1) instead of O(queue.size).

Why no persistence:

  • The state is by definition wall-clock-ephemeral. A permit “for the next 10 minutes” doesn’t make sense to persist across a singleton failover that takes 30 seconds.
  • On singleton failover, the new singleton starts with empty running and queue. Any in-flight crawls keep running on their original nodes; they’ll attempt ReleaseCrawlPermit when they finish, which becomes a no-op against the fresh singleton (the siteId isn’t in running anyway).
  • Callers waiting on a grant when the singleton fails over hit their ask-timeout (5 minutes) and SiteEntity’s normal failure path takes over — typically logging and trying again on the next periodic tick.

This is the same trade-off TokenBucketLimiter makes for the Gemini rate limiter: persist what’s globally meaningful (none in this case), let everything else self-heal.

Idempotency

Three idempotency cases that fall out of the implementation:

  • Already running. A site requesting a permit it already holds gets a re-grant — running.update(siteId, now) refreshes the timestamp (so a long but legitimate crawl doesn’t get stale-evicted) and the caller’s ask completes immediately.
  • Already queued. A site requesting a permit it already has queued gets its ackRef updated in place — most-recent-caller wins. Older asks die at their own timeouts.
  • Release of a non-running site. No-op. Survives the singleton-failover case where running is empty.

These matter for the post-restart scenario specifically. After failover, every site starts re-requesting on its next tick; the idempotency rules ensure those re-requests are cheap and don’t blow up the queue.

The stale-release sweep

case object SweepStale extends Command   // internal, fired on a timer

case SweepStale =>
  val cutoff = now.minusSeconds(settings.staleAfter.toSeconds)
  val stale = running.collect { case (id, t) if t.isBefore(cutoff) => id }
  stale.foreach(releaseAndAdvance)

A staleAfter (10 minutes default) timeout exists for the case where a PlaywrightWorker crashes hard, the JVM dies, or a network partition leaves the scheduler thinking a site is still crawling when actually nothing is. Without the sweep, a single crashed worker would leak a permit forever; eight crashes and the scheduler grants nothing, ever again.

The sweep runs every sweepInterval (1 minute default). Permits older than staleAfter are auto-released, just as if the holder had sent ReleaseCrawlPermit correctly. Logged as a warning so an operator notices.

A real crawl rarely takes longer than a few minutes; 10 minutes is a generous upper bound. A site whose crawl is legitimately taking that long re-requests via the idempotency path, refreshing its timestamp.

Settings

final case class Settings(
    singletonName: String     = "crawl-scheduler",
    maxConcurrent: Int        = 8,
    staleAfter: FiniteDuration = 10.minutes,
    sweepInterval: FiniteDuration = 1.minute,
)

maxConcurrent = 8 is the headroom for one Mac dev machine. Production tunes this against the host’s RAM and CPU; the bottleneck is Chromium memory more than anything else, so the right value is roughly (available_ram_gb - 4) / 0.5 (each Chromium context is ~500 MB working set).

Generic primitive: it’s the same shape as TokenBucketLimiter

The scheduler is structurally identical to promovolve.TokenBucketLimiter:

  • Cluster singleton with persistent identity but ephemeral state.
  • Holds a small in-memory state plus a waiter queue.
  • Idempotent acquire / explicit release semantics.
  • Stale-safe via timeouts.
  • No persistence — failover is acceptable because the state is by-definition wall-clock-ephemeral.

The two differ in what the state is:

TokenBucketLimiterCrawlScheduler
StateToken count (numeric)Running set (siteId → timestamp)
RefillContinuous (tokens / second)Explicit (ReleaseCrawlPermit)
Use caseRPM rate limiting (Gemini, Anthropic)Concurrency capping (crawls)

If a third use case shows up (say, “max-N parallel video transcodes”), the TokenBucketLimiter shape is the right pattern: persistent name, ephemeral state, ask/release protocol, stale safety net. The platform has two of these now; the third would feel boring to build, which is the point.

Source of truth

  • modules/core/src/main/scala/promovolve/crawler/CrawlScheduler.scala — the singleton + protocol
  • modules/core/src/main/scala/promovolve/publisher/SiteEntity.scalaStartCrawling handler with pipeToSelf(scheduler.request) + CrawlerTerminated watcher with release
  • modules/core/src/main/scala/promovolve/cluster/ClusterBootstrap.scala — singleton init wiring
  • modules/core/src/main/scala/promovolve/TokenBucketLimiter.scala — the structurally-identical primitive for rate limiting

Distributed State from Scratch

When a user loads a page, Promovolve needs to find the right ad in under a millisecond. Why not just query a database?

This chapter explains why Promovolve uses replicated in-memory state instead of a database, how it keeps multiple copies in sync without a leader, and why “eventually consistent” is not just acceptable but actually the right choice for ad serving.

The Latency Problem

A PostgreSQL query takes 1-5 milliseconds on a fast local connection. That sounds fast. But the ad serving path runs on every page load, for every ad slot, for every user. At 1,000 requests per second with 3 ad slots each, that’s 3,000 database queries per second — each adding latency to the user’s page load.

Worse, database latency has a long tail. The median might be 2ms, but the 99th percentile might be 20ms. One slow query blocks the response. Under load, connection pool contention adds more. For a publisher who cares about page performance, every millisecond of ad serving latency is a tax on their readers.

Promovolve’s target: serve an ad in under 1 millisecond. No database can deliver that consistently under load. The data needs to be in memory, on the same machine that handles the request.

The Obvious Solution (and Why It Doesn’t Work)

“Just put it in a local cache.” Load the auction results into a HashMap on each API node. Reads are nanoseconds. Problem solved.

Not quite. Promovolve runs as a cluster of multiple nodes (for reliability and throughput). If node A runs an auction and updates its local cache, nodes B and C don’t know about it. A user whose request lands on node B gets stale data — or no data at all.

You need the data replicated across all nodes. The question is: how?

Option 1: Leader-Based Replication

The traditional approach: one node is the “leader” (or “primary”). All writes go through the leader. The leader replicates to followers.

Write → Leader → Follower 1
                → Follower 2
                → Follower 3

This is how PostgreSQL replication, Redis Sentinel, and most databases work. It provides strong consistency — all nodes see the same data after each write.

The problems:

  • The leader is a bottleneck. All writes go through one node. If that node is slow or down, writes stall.
  • Leader failure requires election. Detecting a dead leader, electing a new one, and catching up takes seconds. During that time, writes fail.
  • Network partitions are ugly. If the leader can’t reach some followers, it must choose: keep accepting writes (risking divergence) or stop accepting writes (sacrificing availability).

For a database backing a billing system, these trade-offs are worth it. For an ad serving cache that refreshes every 5 minutes, they’re overkill.

Option 2: Replicate Without a Leader

What if every node can write, and writes automatically propagate to all other nodes?

Node A writes → gossip → Node B receives
Node B writes → gossip → Node A receives

No leader. No election. No single point of failure. Every node accepts writes locally (fast) and syncs with others in the background (eventually).

The problem: what happens when two nodes write different values for the same key at the same time? With a leader, this can’t happen — all writes go through one place. Without a leader, you need a way to resolve conflicts automatically.

CRDTs: Data Structures That Merge Themselves

A CRDT (Conflict-free Replicated Data Type) is a data structure designed so that concurrent writes can always be merged without conflict. The merge is deterministic — no matter what order updates arrive, all nodes converge to the same result.

The simplest example: a counter. Instead of storing “count = 5”, each node stores “my contribution is X”:

Node A: my_count = 3
Node B: my_count = 2
Merge: total = 3 + 2 = 5

Both nodes increment their own counter. The merge just sums them. No conflict possible.

Promovolve uses a more sophisticated CRDT: LWWMap (Last-Writer-Wins Map). It’s a key-value map where each entry has a timestamp. When two nodes write different values for the same key, the one with the later timestamp wins:

Node A writes: key="ad-slot-1" → creative_X at t=1000
Node B writes: key="ad-slot-1" → creative_Y at t=1003
Merge: creative_Y wins (later timestamp)

This is simple and predictable. The “conflict resolution” is just “most recent write wins” — the same semantics as overwriting a variable.

How Promovolve Uses DData

Pekko’s Distributed Data (DData) implements CRDTs with a gossip protocol. Every few seconds, each node shares its data changes with random peers. Changes propagate through the cluster like gossip in a social network — eventually reaching every node.

Promovolve’s ServeIndex stores the auction results that the serve path needs:

Key: "site-123|slot-banner-top|bucket-7"
Value: LWWMap of creative candidates
  "creative-abc" → ServeView(assetUrl, cpm, ctr_stats, expires_at, ...)
  "creative-def" → ServeView(...)

When the AuctioneerEntity completes an auction, it writes results to DData with WriteLocal — the write completes immediately on the local node. Within 2 seconds (the gossip interval), other nodes receive the update.

When the serve path needs to select an ad, it reads from the local DData replica — a local in-memory lookup, no network hop.

Why “Eventually Consistent” Is Fine Here

“Eventually consistent” sounds scary. What if a user gets stale data?

For ad serving, consider what “stale” means in practice:

A creative was updated 2 seconds ago but this node hasn’t received the gossip yet. The user sees the previous creative. Is this a problem? No — the creative was valid 2 seconds ago. It’s still a legitimate ad with a valid tracking URL. The user has no way to notice the difference.

A campaign ran out of budget but the serve node still shows its creative. The pacing gate and budget reservation catch this. Even if the ServeIndex has a stale entry, the budget check (which queries the CampaignEntity directly) will reject the serve and fall back to the next candidate. The stale cache entry is harmless.

An auction ran and produced new candidates, but this node still has the old ones. The old candidates are at most 5 minutes stale (the re-auction interval). They were valid winners of the previous auction. Serving them for 2 more seconds until gossip arrives is fine.

The key insight: the serve path is approximate by design. Thompson Sampling adds randomness. Pacing throttles probabilistically. Click-through rates are estimates. Adding 2 seconds of gossip delay to a system that already operates on statistical estimates doesn’t meaningfully degrade the outcome.

Strong consistency would give you a guarantee you don’t need, at a cost (leader bottleneck, cross-node coordination latency) that directly hurts the thing you do need: speed.

Bucketing: Keeping CRDTs Small

One problem with LWWMap: if you put thousands of entries in a single map, every gossip cycle transmits the entire delta (all changes since last sync). With frequent updates across many creatives, deltas grow large.

Promovolve splits each namespace into 32 buckets by hashing the key:

bucket = hash(creativeId) % 32
key = "site-123|slot-banner|bucket-7"

Each bucket is a separate LWWMap. An auction that updates 10 creatives touches maybe 8-10 buckets, not all 32. Gossip only transmits the buckets that changed. This keeps delta sizes small and gossip efficient.

Why 32? It’s a balance. More buckets means smaller deltas but more DData keys to manage. Fewer means larger deltas but simpler bookkeeping. 32 works well for the typical case of dozens to hundreds of creatives per site.

Write Consistency: Fast Writes, Safe Deletes

Not all writes are equal. Promovolve uses different consistency levels depending on the operation:

Writes (Put, Append, Update CPM): WriteLocal

The write succeeds immediately on the local node. Gossip propagates it. If the local node crashes before gossip, the write is lost — but the next auction (within 5 minutes) will repopulate it.

This is the right trade-off for the hot path. Auction results are ephemeral and refreshed frequently. Speed matters more than durability.

Deletes (Remove campaign, Remove creative): WriteMajority

Removing a creative should be seen by all nodes quickly — you don’t want a paused campaign’s ad to keep serving because one node missed the delete. WriteMajority waits for acknowledgment from a majority of nodes (e.g., 2 out of 3) before confirming.

If WriteMajority times out (800ms), Promovolve retries up to 5 times with 200ms backoff. Removing an ad that shouldn’t serve is more important than speed.

What About Node Restarts?

DData is in-memory. If a node restarts, its local replica is empty. What happens?

In a multi-node cluster: The restarted node receives the full state from other nodes via DData’s anti-entropy protocol. Within one gossip cycle (2 seconds), it has a complete replica.

In a single-node cluster (development): The data is gone. The next PeriodicReauction timer (within 5 minutes) re-runs auctions and repopulates the ServeIndex. During the gap, the serve path returns NoContent (HTTP 204) — the ad slot is empty. Not ideal, but bounded.

Promovolve deliberately does not persist ServeIndex to disk (unlike the shard-* keys that use LMDB). Persisting the hot serve path would add disk I/O to every auction write, which defeats the purpose of in-memory state for sub-millisecond reads. The 5-minute recovery window is an acceptable trade-off.

The Full Picture

Auction completes
  → AuctioneerEntity writes to ServeIndex (WriteLocal, ~0ms)
  → Gossip propagates to all nodes (~2 seconds)
  → Every node has candidates in local memory

User loads page
  → API node reads local ServeIndex replica (~0.1ms)
  → Pacing gate + Thompson Sampling (~0.1ms)
  → Ad response sent (<1ms total)

No database in the serve path. No network hop for reads. No leader to bottleneck. No election to delay. The system trades strong consistency (which it doesn’t need) for speed (which it does).

From Theory to Code

ConceptFileKey method
ServeIndex DData actorServeIndexDData.scalaPut, Append, Remove commands
Bucketed LWWMap keysServeIndexDData.scalamapKey(pub, bucket)
WriteLocal vs WriteMajorityServeIndexDData.scalaReplicator.WriteLocal, Replicator.WriteMajority
TTL sweep (expire stale entries)ServeIndexDData.scalaSweep command
Gossip and replication configapplication.confpekko.cluster.distributed-data
DData adapter in serve pathAdServer.scalaServeIndexDData lookup

The next chapters cover the bucketed LWWMap design, TTL expiration, and write consistency levels in detail.

ServeIndex & DData

The ServeIndex is Promovolve’s distributed in-memory cache storing auction results for instant serve-time lookups, built on Pekko Distributed Data (DData).

Why DData?

Every API node must serve ads without network round-trips:

AlternativeProblem
Database (PostgreSQL)1-10ms per query
Remote cache (Redis)~0.5ms network hop
Sharded in-memoryRequires request routing
DDataLocal replica on every node, gossip replication

Data Model

ServeIndex
  └── Bucket[0..31]  (32 buckets, power-of-2)
        └── LWWMap[String, ServeView]
              Key:   "siteId|slotId"
              Value: ServeView

ServeView

case class ServeView(
  candidates: Vector[CandidateView],
  version: Long,       // e.g., auction timestamp
  expiresAtMs: Long    // epoch millis; for TTL sweep
) extends CborSerializable

CandidateView

case class CandidateView(
  creativeId: CreativeId,
  campaignId: CampaignId,
  advertiserId: AdvertiserId,
  assetUrl: CDNPath,          // URI to CDN-hosted asset
  mime: MimeType,             // imageJpeg, imagePng, imageGif, imageWebp, videoMp4
  width: Int,
  height: Int,
  category: CategoryId,
  cpm: CPM,
  classifiedAtMs: Long,       // when page content was classified
  categoryScore: Double = 0.5, // classifierConfidence × rankerWeight
  frequencyCap: Option[Int] = None,
  adProductCategory: Option[AdProductCategoryId] = None,
  landingDomain: String = ""
) extends CborSerializable

DData Configuration (from application.conf)

SettingValue
Gossip interval2s
Notify subscribers500ms
Max delta elements500
Durable keysshard-*, exhausted-campaigns
Durable storeLMDB (100 MiB, 200ms write-behind)
Pruning interval120s

Note: ServeIndex entries are not in the durable keys list — they are ephemeral and rebuilt from auctions on restart. Only shard metadata and exhausted-campaign flags are LMDB-durable.

Bucketed LWWMap Design

The ServeIndex partitions entries into 32 buckets to keep CRDT delta sizes small.

Why Buckets?

A single LWWMap containing all entries would produce large deltas on any change. Bucketing partitions the keyspace:

bucket = abs(key.hashCode) % 32

Each bucket is an independent LWWMap. An update to bucket 7 only produces a delta for bucket 7.

LWWMap (Last-Writer-Wins Map)

Conflicts are resolved by timestamp — the value with the higher timestamp wins. This is safe because:

  1. Auction results are timestamped by the auction itself
  2. Newer auctions should always override older ones
  3. Concurrent auctions for the same slot are impossible (AuctioneerEntity is sharded by siteId)

Bucket Count: Why 32?

  • Too few (4): ~25% of entries per bucket → large deltas
  • Too many (1024): CRDT management overhead outweighs savings
  • 32: With 10,000 entries, ~312 per bucket. Balanced.

Per-Publisher Namespace

The composite key "siteId|slotId" naturally partitions entries by publisher. Slots from different sites land in different buckets (usually) due to hash distribution.

DData Gossip Impact

With 32 buckets and max-delta-elements of 500:

  • Each gossip round can propagate up to 500 changes across all buckets
  • A single auction updating 10 slots affects at most 10 buckets
  • Other buckets’ gossip is unaffected

TTL Sweep & Expiration

ServeIndex entries have a time-to-live to prevent stale ads from serving indefinitely.

TTL Assignment

When writing auction results:

expiresAtMs = System.currentTimeMillis() + ttlDurationMs

Default TTL: 120 minutes. Under normal operation, the next auction refreshes the entry before TTL expires.

Budget Exhaustion TTL Refresh

On CampaignBudgetExhausted or AdvertiserBudgetExhausted:

expiresAtMs = System.currentTimeMillis() + (dayDurationSeconds × 1.1 × 1000)

The 1.1x factor ensures the entry survives until well past the next daily budget reset.

Periodic Sweep

From ServeIndexDData.scala:

SweepInterval = 2.minutes
MaxKeysRemovePerRun = 500

Every 2 minutes, each node scans all 32 buckets:

for each bucket:
    entries = bucket.entries
    expired = entries.filter(e => now > e.expiresAtMs)
    remove up to 500 expired entries from this bucket

Bounded Removals

The 500-per-bucket limit prevents a large batch of expirations from overwhelming DData:

  • 32 buckets × 500 = up to 16,000 entries per sweep
  • In practice, expirations are spread across time, so batches are smaller

Why Not Instant Expiration?

ApproachProblem
Instant expiryClock skew between nodes → entries flicker
Individual removesMany small deltas → gossip overhead
Batched sweepPredictable load, clock-skew tolerant

The 2-minute sweep interval means an expired entry might serve for up to 2 extra minutes. This is acceptable — the pacing gate and budget checks provide additional safety at serve time.

Write Consistency Levels

DData supports different consistency levels. Promovolve uses different levels depending on operation criticality.

Consistency Choices (from ServeIndexDData.scala)

OperationConsistencyTimeoutRetriesRationale
Put (full replacement)WriteLocalSpeed; next auction refreshes
Append (single candidate)WriteLocalSpeed; dedup prevents issues
CPM updateWriteLocalBest-effort price refresh
FilterByCreativeIdsWriteLocalBatch cleanup
Remove (takedown)WriteMajority800ms5 (200ms backoff)Must be durable
RemoveCampaignFromKeyWriteMajority800ms5Must be durable
RemoveCreativeFromKeyWriteMajority800ms5Must be durable
RemoveBySiteWriteMajority800ms5Must be durable

Why WriteLocal for Puts?

Auction results are written frequently and losing one write is not catastrophic:

  • The next crawl cycle produces fresh results
  • Gossip replicates to other nodes within seconds (2s gossip interval)
  • Stale data is caught by the TTL sweep

Why WriteMajority for Removes?

Removes must be durable. If a remove only reaches one node and that node crashes:

  • The entry reappears on restart from other nodes’ copies
  • A “zombie” creative that was supposed to be taken down continues serving
  • This is a compliance/safety concern (paused campaigns, suspended advertisers)

WriteMajority ensures the remove is acknowledged by a majority of nodes before returning.

Retry Strategy

MaxRemoveRetries = 5
InitialRetryBackoff = 200.millis

If WriteMajority times out (800ms), the remove is retried with exponential backoff. After 5 failures, the removal is logged and will be caught by the next TTL sweep.

Eventual Consistency Window

WriteLocal operations have a brief window (typically <2s, matching gossip interval) where different API nodes see different ServeIndex contents. This means:

  • Two concurrent requests to different nodes might get different creatives
  • A just-written entry might not be visible everywhere immediately

These are acceptable because:

  1. Thompson Sampling already introduces per-request randomness
  2. The 15-minute RL window averages over many decisions
  3. Budget and pause checks at serve time catch any “shouldn’t serve” cases

Promovolve vs SSP/DSP/Exchange

This chapter maps Promovolve’s design choices against the traditional programmatic advertising stack.

Traditional Programmatic Stack

graph LR
    Publisher["Publisher<br/>(webpage)"] --> SSP["SSP<br/>(Supply)"]
    SSP --> Exchange["Exchange<br/>(auction)"]
    Exchange --> DSP1["DSP1"]
    Exchange --> DSP2["DSP2"]
    Exchange --> DSP3["DSP3"]

Flow: User loads page → SSP sends bid request → Exchange broadcasts to DSPs → DSPs respond within 100ms → Highest bid wins → Ad served.

Promovolve Stack

Promovolve collapses the SSP, DSP, and exchange into a single system with two distinct phases: an offline auction phase that runs ahead of time, and an online serve phase that responds to user requests.

Phase 1: Offline Auction (no user present)

graph LR
    Crawler["Crawler<br/>(scheduled)"] --> Auctioneer["Auctioneer<br/>(Pekko Shard)"]
    Auctioneer --> ServeIndex["ServeIndex<br/>(DData)"]
  1. Crawler periodically fetches publisher pages and sends them to an LLM (Gemini Flash) for content classification into IAB taxonomy categories.
  2. AuctioneerEntity — one per site, sharded across the Pekko cluster — runs a batch auction. It collects bids from all campaigns whose target categories match the page content, applies pacing throttles, and shortlists multiple candidates per ad slot (not just a single winner). Bids are honest CPMs; quality-adjusted second-price clearing at serve time means there’s no upside to bid shading, so no campaign-side bid optimizer is needed.
  3. ServeIndex — a replicated in-memory cache built on Pekko Distributed Data (DData) — stores the shortlisted candidates. Every node in the cluster holds a local replica, so no remote call is needed at serve time.

This phase re-runs on a schedule (every 5 minutes by default) and whenever content changes, keeping candidates fresh without waiting for a user to arrive.

Phase 2: Online Serve (user arrives)

graph LR
    User["User<br/>(browser)"] --> APINode

    subgraph APINode["API Node"]
        ServeIndex["ServeIndex<br/>(local DData replica)"] --> TS["Thompson Sampling<br/>+ Pacing"]
    end

The ServeIndex is not a separate service — it’s a DData-replicated data structure, and every API node holds a local replica in its own process memory. There is no network call between the API node and the ServeIndex; it’s a local in-memory lookup.

  1. User requests an ad for the page they’re viewing.
  2. API Node reads pre-computed candidates directly from its local ServeIndex replica — no network hop, no auction, no external call.
  3. Thompson Sampling selects among the shortlisted candidates, balancing exploration of new creatives against exploitation of known performers. A pacing check ensures the selected campaign hasn’t exhausted its budget for this time window.

The result: serve latency under 1ms, with no user data collected, no cookies set, and no third-party calls made.

What replaced what

Traditional rolePromovolve equivalent
SSP (supply-side platform)Crawler + AuctioneerEntity — the publisher’s inventory is discovered by crawling, not by firing bid requests
Exchange (auction house)AuctioneerEntity + Thompson Sampling at serve time — quality-adjusted second-price clearing
DSP (demand-side platform)Campaign entities — advertisers post a CPM and the auction extracts honest bids; no separate bid-management system
Ad serverAPI Node + local DData replica — serves pre-computed results from memory
DMP (data management platform)Not needed — targeting is content-based, not user-based
Creative-production pipelineLP-to-creative pipeline — Playwright extraction + Gemini rewriting + in-house designer renders fluid creatives that flow to fit the slot
RetargetingDog-ear pin — reader-driven bookmark stored in the reader’s own browser, not a server-side profile

Summary Comparison

AspectTraditional SSP/DSPPromovolve
Ad formatStatic IAB rectangles (300×250, 728×90, …)Expandable, multi-page magazine creatives that flow to fit the slot
Reader agencyNoneDog-ear pin — reader bookmarks an ad to revisit
Auction timingPer-request (realtime)Per-crawl + 5-min re-auction
Serve latency50–200ms< 1ms
Winner selectionHighest bid winsFair selection → Thompson Sampling
Price modelSecond-price (GSP) on bids onlyQuality-adjusted second-price: sampledCTR × CPM^α
Price discoveryYes (competitive)Yes (competitive, quality-adjusted)
LearningRTB feedback loopsTS + category ranking + traffic shape + publisher-side floor RL
Candidate modelSingle winnerMulti-candidate with diversity
Budget controlPer-campaign throttlingAggregate PI-controlled pacing
State persistenceDatabase/RedisDData (replicated in-memory)
Content scopeAny page, any timeRecency only (< 48h)
TargetingUser profiles, cookiesContent classification (LLM)
Failure modeNo ad shownServe cached candidates
PrivacyUser tracking requiredNo user profiles; even pins live in the reader’s browser

The following sub-chapters explore each difference in detail.

Auction Timing: Periodic vs Realtime

The most fundamental difference between Promovolve and traditional ad tech is when the auction runs.

Traditional: Per-Request Auctions

t=0ms    User loads page
t=5ms    SSP sends bid request to exchange
t=10ms   Exchange broadcasts to DSPs
t=80ms   DSPs respond with bids
t=85ms   Exchange picks winner
t=90ms   Ad creative URL returned
t=200ms  Ad renders on page

Advantages: Fresh bidding, competitive price discovery Disadvantages: 50-200ms latency, auction QPS = page QPS, failure = empty slot

Promovolve: Periodic Batch Auctions

Crawl time (background, 2am daily + 5-min re-auctions):
  t=0s     Crawler classifies page (LLM)
  t=1s     AuctioneerEntity starts auction
  t=3s     Bids collected (800ms timeout for taxonomy)
  t=4s     Candidates cached in DData

Serve time (user-facing):
  t=0.0ms  User loads page
  t=0.1ms  Local DData lookup
  t=0.2ms  Pacing gate + Thompson Sampling
  t=0.3ms  Ad response sent

Advantages: Sub-ms serving, bounded compute, graceful failure, exploration Disadvantages: Stale bids (up to 5 min between re-auctions), no user-level signals

When Periodic Wins

  1. Content is the signal, not the user: Promovolve targets content categories via LLM classification. Content changes slowly, so periodic auctions suffice.
  2. Single publisher control: No cross-publisher price discovery needed.
  3. Serve latency matters: Adding 100ms per ad slot is unacceptable for performance-conscious publishers.
  4. Exploration has value: The publisher wants to learn which creatives engage users, not just which advertiser pays most.

The Refresh Cycle

Promovolve’s re-auction interval (5 minutes) is a middle ground:

  • Fresh enough to react to campaign budget changes
  • Infrequent enough to avoid overwhelming entity actors
  • Candidates in DData with 120-minute TTL survive multiple re-auction cycles

Winner Selection: MAB vs Highest Bid

Traditional exchanges pick a single winner — the highest bidder — and move on. Promovolve takes a fundamentally different approach: it shortlists multiple candidates at auction time, then uses Thompson Sampling at serve time to learn which creative actually performs best.

Traditional: Highest Bid Wins

In a standard ad exchange, winner selection is simple:

Bids received:    $8.00,  $5.50,  $3.20
Winner:           $8.00
Price paid:       $5.51   (second-price: winner pays $0.01 above second bid)

The logic is purely financial: whoever is willing to pay the most gets the impression. The creative’s quality, relevance to the page, or likelihood of being clicked plays no role in the decision.

Why this is a problem

CTR is invisible. A campaign bidding $8 CPM with a 0.5% click-through rate beats a campaign bidding $3 CPM with a 5% CTR. The publisher serves a worse ad and makes less money per click. The advertiser with the better creative loses despite offering more value to readers.

There is no learning mechanism. The exchange doesn’t track whether the winning creative gets clicked. It doesn’t know if the $8 bid was worth it. Each auction is independent — the system never gets smarter.

The winner’s curse. In a competitive auction, the highest bidder is statistically the one who overestimated the impression’s value the most. Sophisticated DSPs account for this; small advertisers don’t, and overpay.

New entrants can’t compete. A new advertiser with a potentially excellent creative but a conservative bid never wins, never gets impressions, and therefore never has a chance to prove itself. The system has no exploration — only exploitation of whoever bids highest today.

Promovolve: A Two-Phase Selection System

Promovolve splits winner selection into two phases: fair shortlisting at auction time (when content is crawled) and adaptive selection at serve time (when a user arrives). This separation is the key design difference.

Phase 1: Auction-Time Fair Shortlisting

Instead of picking a single winner, the AuctioneerEntity shortlists multiple candidates per ad slot with a per-campaign diversity guarantee:

3 campaigns, 3 slots → each campaign gets exactly 1 slot
2 campaigns, 3 slots → each gets 1, fill the 3rd with the best remaining creative
4 campaigns, 3 slots → top 3 by CPM each get 1 slot

The algorithm:

  1. Sort all candidates by CPM descending, with pre-approved creatives preferred as a tiebreaker (publishers can approve creatives before they enter the auction — approved creatives win ties over unapproved ones)
  2. Group by campaign, pick the best creative per campaign
  3. If there are more campaigns than slots, the top campaigns by CPM each get one slot
  4. If there are fewer campaigns than slots, every campaign is guaranteed representation, and remaining slots are filled with next-best creatives

Why this matters: In a traditional exchange, a single high-budget campaign can monopolize every impression. Promovolve’s diversity guarantee ensures that every participating campaign gets representation in the candidate pool, giving Thompson Sampling a diverse set to learn from.

Phase 2: Serve-Time Thompson Sampling

When a user loads a page, Thompson Sampling selects among the shortlisted candidates. The scoring formula:

score = sampledCTR × CPM^α

Where sampledCTR is drawn from a Beta distribution based on observed performance over a 60-minute rolling window:

sampledCTR ~ Beta(clicks + 1, impressions - clicks + 1)

The exponent α (bidWeight) is publisher-configurable and controls how aggressively price competes with quality:

αProfileEffect
0.3DiscoveryQuality dominates; small advertisers compete
0.5Balancedsqrt(CPM) — the default
0.7RevenueHigher bids win more often

At α=0.5 a $10 CPM is only ~3.2× better than a $1 CPM (not 10×). CTR is the multiplicative factor: a creative that readers actually click beats one that merely bids high.

A worked example

Three campaigns competing for the same slot at the default α=0.5. Campaign C is brand new with no data:

Campaign A: $5.00 CPM, 150 impressions, 5 clicks
  Beta(6, 146) → sample: 0.032
  score = 0.032 × √5.00 = 0.0716

Campaign B: $4.20 CPM, 22 impressions, 3 clicks
  Beta(4, 20) → sample: 0.091
  score = 0.091 × √4.20 = 0.1865

Campaign C: $3.80 CPM, 0 impressions, 0 clicks
  Beta(1, 1) → sample: 0.647  (uniform — could be anything)
  score = 0.647 × √3.80 = 1.261  ← wins (exploration)

Campaign C wins this request despite having the lowest CPM and no track record. This is exploration — the system gives the new creative a chance to prove itself. Over the next few dozen impressions, if C’s true CTR turns out to be low, its Beta distribution narrows and it stops winning. If C turns out to be genuinely good, it earns a stable share of impressions.

Pricing: quality-adjusted second-price

The exploiting winner doesn’t pay its own bid. The selector records the next-best loser’s score, then computes the minimum CPM at which the winner’s score still beats that runner-up given its sampled CTR:

clearingCPM = (bestLoserScore / sampledCTR_winner) ^ (1/α)

Clamped to the site floor and to the winner’s actual bid. A creative that earns a high sampled CTR therefore pays less than one that merely outbid; a creative bidding well above the runner-up gets the price compressed back toward what would have actually been needed to win. There is no upside to bid shading, so Promovolve runs no campaign-side bid optimizer at all.

Cold-start serves clear at the floor. Pinned re-encounters (see below) bypass clearing entirely — they’re free.

A traditional exchange would never serve Campaign C. It would never get data. It would never have a chance.

Cold start: getting new creatives off the ground

Thompson Sampling needs data to work, so Promovolve uses specific strategies for new creatives depending on the state of the candidate pool:

ConditionStrategyBehavior
All candidates have 0 impressionsFull cold startUse categoryScore ± noise as estimated CTR — the auction’s content-relevance signal bootstraps the first selections
All candidates have < 10 impressionsWarmup round-robinAlways serve the candidate with the fewest impressions — ensures every creative gets at least 10 impressions before exploitation begins
Some candidates are new, some have dataPartial cold start30% of the time, randomly pick a new creative; 70% run normal Thompson Sampling on all candidates
All candidates have ≥ 10 impressionsStandardFull Thompson Sampling

The 30% exploration rate for partial cold starts is aggressive by design — new creatives need data quickly, and Thompson Sampling’s natural exploration handles the rest once they have enough impressions.

The full selection pipeline

Thompson Sampling doesn’t run in isolation. It’s one step in a pipeline, and its position in that pipeline is deliberate:

1. ServeIndex lookup     → fetch cached candidates from local DData replica
2. Pin-honor check       → if the slot carries a dog-ear pin and the pinned
                           creative is still in the pool, bypass everything
                           below and serve the pin (free re-encounter)
3. Content recency       → drop candidates if page content is stale (> 48h)
4. Frequency cap         → drop candidates the user has seen too many times
5. Pacing gate           → probabilistic throttle based on aggregate budget utilization
6. Thompson Sampling     → score and select among remaining candidates
7. Budget reservation    → reserve spend with the selected campaign

Why pin-honoring runs first: A dog-ear is the reader saying “I want to come back to this ad.” Subjecting the pin to pacing gates or frequency caps would let throttling discard a bookmark the reader explicitly asked for. Pinned slots also skip CPM reservation — the re-encounter is treated as a free engagement signal, not a billable serve.

Why pacing runs before Thompson Sampling: If pacing ran after selection, Thompson Sampling would pick a creative, then pacing would sometimes throw it away. That wastes an exploration opportunity — we showed nothing, we learned nothing. By putting the pacing gate first, every request that makes it to Thompson Sampling produces a served impression and useful data.

Budget reservation and graceful fallback

After Thompson Sampling selects a winner, the system attempts to reserve the spend:

1. Reserve spend with CampaignEntity
2. Verify budget status with AdvertiserEntity
3. On failure (budget exhausted) → try the next-best candidate by Thompson score
4. All candidates exhausted → return NoCandidates (HTTP 204)

In a traditional exchange, if the winner can’t pay, the auction fails and the slot goes unfilled (or a low-quality fallback ad appears). Promovolve’s multi-candidate model means there’s always a next-best option waiting — graceful degradation without re-running the auction.

Side-by-Side Comparison

DimensionTraditional (Highest Bid)Promovolve (Thompson Sampling)
Selection criterionPrice onlysampledCTR × CPM^α (α publisher-tunable)
Number of candidates1 winnerMultiple shortlisted per slot
PricingSecond-price on bidsQuality-adjusted second-price — winner pays the minimum CPM that still beats the runner-up given its CTR
Reader agencyNone — readers can’t influence what they seeDog-ear pins: readers bookmark ads they want to revisit
LearningNone — each auction is independentContinuous — every impression updates the Beta posterior
New creative discoveryImpossible without outbidding the incumbentBuilt-in via exploration; 30% forced exploration for brand-new creatives
Cold startNew advertiser must bid high to winRound-robin warmup guarantees initial data collection
Failure handlingSlot goes unfilledFall through to next-best candidate
Publisher alignmentOptimizes for advertiser spendOptimizes for reader engagement (CTR), weighted by spend via α
Short-term revenueHigher (always picks highest bid)Sometimes lower (explores lower-CPM creatives)
Long-term revenueStagnant (no learning)Higher (discovers high-CTR creatives that earn more clicks)
Advertiser ROIFavors large budgetsFavors creative quality — a good ad at $3 CPM can beat a bad ad at $8 CPM

Learning Mechanisms

Both traditional ad tech and Promovolve learn from feedback, but at different levels and timescales. The fundamental difference: traditional RTB learns about users, Promovolve learns about content and creatives.

Traditional: RTB Feedback Loops

DSP-Side

  • Bid values: Learn from win/loss notifications what impressions are worth
  • Audience targeting: Learn from conversions which user segments are valuable
  • Learning happens at the bid level, not the creative level

Exchange-Side

  • Floor prices: Learn from bid distributions to set minimum acceptable bids

Limitations

  • No exploration: only learns from wins, never discovers if alternative creatives would perform better
  • User-dependent: requires cookies, profiles, cross-site tracking

Promovolve: Five-Layer Learning

Promovolve does not run any campaign-side bid optimizer. With quality-adjusted second-price clearing (see Winner Selection), bid shading is counterproductive — the auction mechanism already extracts honest bids. The learning that does happen runs on the publisher and content sides.

Layer 1: Thompson Sampling (Per-Request)

What: Which creative performs best for a given slot Timescale: Every impression updates CreativeStats (1-minute buckets, 60-minute window) How: Bayesian posterior update Beta(clicks+1, impressions-clicks+1)

Before impression: Beta(5, 95)  → mean 5%
User clicks:       Beta(6, 95)  → mean 5.9%

This is the fastest loop — sub-second feedback incorporated into the next serve decision.

Layer 2: Publisher-Side Floor CPM RL (Per-Site, Slow)

What: The minimum CPM the publisher will accept on a given site Timescale: Adjustments evaluated against rolling served revenue How: An RL agent observes bid spread, fill rate, and post-pacing served revenue, and tunes the floor up when the market shows headroom (bid spread > 1.5×) and down when fill suffers

The agent is gated: it only activates when bid spread is wide enough that floor adjustments can plausibly change outcomes. In a homogeneous market where every bidder offers the same CPM, the agent stays put — moving the floor would just collapse fill without raising revenue.

This is the only RL agent in the system. It runs on behalf of the publisher; advertisers see honest second-price clearing regardless of what the floor does.

Layer 3: Category Ranking (Per Auction)

What: Which content categories are valuable for each site Timescale: Updated every auction, 7-day half-life decay, 14-day max age How: Thompson Sampling with Beta(prior_α + clicks, prior_β + non_clicks)

Category "sports" on site X:
  Prior: Beta(1, 1) → weight sampled ~0.5
  After data: Beta(15, 95) → weight sampled ~0.14

Slow loop, learning site-level category affinities.

Layer 4: Traffic Shape (Per Day)

What: Hourly traffic patterns (separate weekday/weekend) Timescale: Daily rollover with dayAlpha = 0.2 blend How: 24-bucket EMA with alpha = 0.1

Adjusts pacing targets to match actual traffic distribution.

Layer 5: PI Self-Tuning (Continuous)

What: Optimal aggressiveness for overpace correction Timescale: Every 20 samples, min 500ms interval How: Overpace multiplier adapts between 1.5× and 5.0× based on persistent overspend detection

Reader-Driven Signals (Not Learning, But Adjacent)

Promovolve also accepts an explicit signal from the reader: the dog-ear. When a reader folds the corner of a creative, the pin lives in their browser and re-encounters of that advertiser surface the bookmarked creative. This isn’t a learning loop in the statistical sense — it’s a direct reader vote, more reliable than any inferred preference. See Why Promovolve?.

Comparison

DimensionTraditional RTBPromovolve
What’s optimizedBid priceCreative + category + pacing + floor
ExplorationNoneBuilt-in (Thompson Sampling)
Learning layers1 (bid-level)5 (per-request through publisher-side floor RL)
User targetingYes (profile)No (content-based)
Reader signalNoneDog-ear pin (explicit bookmark)
Privacy impactHigh (tracking)Low (no user profiles)
Cold startHistorical bid dataBayesian priors + round-robin warmup
AdaptabilityReal-time bidsTS adapts per-request; floor RL converges over days

Key Innovations

Promovolve’s design choices form a coherent system where each innovation enables the next.

1. The Magazine Format

Traditional: Static rectangles in fixed IAB pixel sizes (300×250, 728×90, 970×250).

Promovolve: Expandable, multi-page creatives. The collapsed view sits in the publisher’s slot like a magazine ad on a page; tapped, it opens into a full-screen overlay the reader can swipe through (cover, story pages, call to action), then collapses again.

Why it matters: Attention is given, not stolen. The advertiser pays for an impression; if the reader chooses to expand, the advertiser earns engaged time. Print-style creative work translates directly. And because the format is a container, not a fixed pixel size, a single creative renders into any slot the publisher offers (see §2).

2. Fluid Creatives

Traditional: Creatives are pinned to specific IAB dimensions. Mismatched slots get letterboxing, scaling artifacts, or no fill.

Promovolve: One creative reflows to fit whatever rectangle the publisher provides. The pipeline (Playwright extraction from a landing page → Gemini rewriting → in-house designer rendering) outputs a layout the slot’s geometry drives at serve time.

Why it matters: Advertisers stop maintaining N variants of every creative. Publishers stop forcing slot dimensions to match available inventory. Small-business advertisers — who never had the resources for N-variant production — can participate.

3. The Dog-Ear (Reader Bookmarks)

Traditional: Readers can’t influence which ads they see. Retargeting is an advertiser-controlled chase.

Promovolve: Readers fold the corner of a creative they want to remember; the next time that advertiser is eligible on a page they visit, the bookmarked creative is the one served. The pin lives in the reader’s browser (IndexedDB) and is signed by a stateless FoldToken so the server never stores who folded what.

Why it matters: An explicit reader vote is more reliable than any inferred preference, and it puts agency on the right side of the table. Pinned re-encounters bypass auction reservation and pacing throttle — the system treats them as free engagement signals.

4. Content-Based, Not User-Based

Traditional: User profiles, cookies, device fingerprinting power ad targeting.

Promovolve: Targeting uses LLM-based content classification (Gemini/OpenAI/Anthropic) to match ads to page topics. No user tracking, no cookies.

Why it matters: Privacy-preserving (no GDPR/CCPA data collection), simpler infrastructure (no profile database), content-value alignment (ads match what the user is currently reading).

5. Recency-Only Monetization

Traditional: Ads on any page, regardless of publication date.

Promovolve: Only content within the 48-hour recency window participates. AuctioneerEntity prunes older classifications every 5 minutes.

Why it matters: Fresh content has higher engagement → higher CTR → better outcomes for all participants. Reduces low-quality inventory.

6. Periodic Batch Auctions

Traditional: One auction per page load.

Promovolve: One auction per crawl (scheduled via Quartz cron) + 5-minute re-auctions.

Why it matters: Decouples auction cost from traffic. Sub-millisecond serving via DData local replica. Enables multi-candidate caching.

7. Fair Selection + Multi-Candidate MAB

Traditional: Single winner per auction.

Promovolve: Per-campaign diversity guarantee at auction time (one creative per campaign first), then Thompson Sampling explores among cached candidates at serve time.

Why it matters: Discovers which creative actually engages users. Graceful degradation on budget exhaustion. Self-correcting (poor creatives lose share naturally).

8. Quality-Adjusted Second-Price Clearing

Traditional: Highest bid wins; second-price (Vickrey) is the gold standard but ignores creative quality.

Promovolve: Score is sampledCTR × CPM^α (α publisher-tunable). The exploiting winner pays the minimum CPM that would still have beaten the runner-up given its sampled CTR — a quality-adjusted second price.

Why it matters: A creative readers click outscores one that merely bids high. There’s no upside to bid shading, so Promovolve runs no campaign-side bid optimizer at all — the auction mechanism itself extracts honest bids.

9. Self-Tuning PI Pacing

Traditional: Simple rules (“spend X% by noon”) or fixed-gain controllers.

Promovolve: PI controller with:

  • Adaptive gains scaled by traffic volatility (CV)
  • Self-tuning overpace multiplier (1.5×–5.0×, adjusts every 20 samples)
  • Oscillation detection (stddev threshold 0.08 → dampening)
  • Leaky integrator (decay 0.995, anti-windup)
  • Cross-day learning (boosts multiplier if budget exhausted early)
  • Traffic shape awareness (separate weekday/weekend 24-hour profiles)

Why it matters: Adapts to any traffic pattern without manual tuning. Learns from past days’ mistakes.

10. Publisher-Side Floor RL

Traditional: Floors are static or set by exchange-side heuristics outside the publisher’s view.

Promovolve: A per-site RL agent tunes the publisher’s minimum floor CPM based on observed bid spread and post-pacing served revenue. Activates only when bid spread is wide enough to make floor adjustments meaningful (>1.5×).

Why it matters: The publisher’s tool, not the exchange’s. Honest second-price clearing for advertisers; floor optimization for the publisher.

11. Buffered At-Least-Once Spend Recording

Traditional: Database writes per impression.

Promovolve: Spend events buffered (500ms timer OR batch of 20), deduplicated via Bloom filter (50K entries, 0.01% FPP), at-least-once delivery with exponential backoff retries.

Why it matters: Reduces persistence load by ~20× while maintaining correctness guarantees.

The Unified Picture

graph TD
    LLM["LLM content classification"] --> Auction["Periodic batch auction<br/>(content changes slowly)"]
    Auction --> Fair["Fair selection<br/>(per-campaign diversity guarantee)"]
    Fair --> DData["DData cache<br/>(multi-candidate, replicated locally)"]
    DData --> Pin["Dog-ear pin check<br/>(reader bookmark wins if eligible)"]
    Pin --> TS["Thompson Sampling<br/>(quality-adjusted score, second-price clearing)"]
    TS --> PI["Self-tuning PI pacing<br/>(smooth delivery within days)"]
    PI --> Spend["Buffered spend recording<br/>(correctness at scale)"]
    PI -.-> FloorRL["Publisher-side floor RL<br/>(slow loop, post-pacing revenue)"]
    Spend --> FloorRL

Each choice enables the next. Remove the magazine format and the dog-ear has nowhere to live; remove fluid creatives and the format collapses back to fixed-size banners; remove quality-adjusted clearing and bid shading returns. Together, they create an ad platform that is fast, learning, privacy-preserving, reader-respectful, and publisher-aligned.