AdPod Lab is a Go-based CTV ad-decisioning simulation. It finds ad identity across fragmented demand sources, constructs revenue-aware ad pods, and applies household frequency caps to reduce ad repetition.
The two-day MVP deliberately runs as one local Go process. JSON fixtures and a concurrency-safe in-memory store keep it easy to run, test, and reason about without external infrastructure.
CTV viewers can get the same ad multiple times in one ad break or repeatedly across channels and devices. Ad decisioning must balance viewer experience with incomplete creative identifiers, protected inventory, fixed pod duration, household exposure history, and competing CPMs.
Given ads from multiple demand sources, inconsistent creative identifiers, household viewing history, contractual restrictions, and competing CPMs, construct an ad pod that minimizes repetition while preserving revenue, fill rate, and low decision latency.
The MVP models these constraints with fixtures and measures local decision latency. Its single-process implementation doesn't claim production-scale latency or multi-instance correctness.
The simulator reports its tradeoffs rather than assuming them. Results are generated, not real production results.
| Capability | Two-day MVP |
|---|---|
| Runtime | Go and the standard library |
| Candidate input | JSON candidate, creative, and identity fixtures |
| Identity resolution | Synthetic UniversalAdId/ACIF-style metadata, exact media hash, and fingerprint fixtures |
| Frequency state | In-memory map protected by sync.Mutex |
| Concurrency | Atomic reserve, confirm, and cancel operations within one process |
| Simulation | Seeded viewing events, playback outcomes, and isolated baseline-versus-optimized replays |
| Metrics | Selection, settlement, revenue, fill, duplicate, and canonical cap metrics |
| Verification | Unit tests plus go test -race ./... |
No Docker, Redis, external exchange, video processing, or streaming integration is required to build or run the MVP.
Seeded Viewing Simulator
|
v
JSON Candidate Fixtures
|
v
Creative Identity Resolver
|
v
Ad Pod Decision Engine
(protected inventory, CPM selection, deduplication)
|
v
In-Memory FrequencyStore Interface
map + sync.Mutex; atomic reservations
|
+--------+--------+
| |
v v
confirm impression cancel / expire reservation
| |
+--------+--------+
v
Metrics and Comparison Report
The frequency store is accessed only through an interface. This keeps the optimizer independent of storage and makes a later shared-state implementation a contained change.
The simulator first creates one complete, read-only viewing history. It then plays that exact history twice, starting each run with separate exposure records. The baseline remembers ads separately for each device and trusts the ID supplied by each ad source. The optimized run recognizes when different source IDs represent the same commercial and shares that history across the household's devices. Both runs use the same candidate ads, simulated clock, and playback results, so the comparison changes the ad-selection rules rather than the viewing behavior.
type FrequencyStore interface {
Reserve(ctx context.Context, householdID, creativeID string,
limit int, window, ttl time.Duration) (reservationID string, ok bool, err error)
Confirm(ctx context.Context, reservationID string, at time.Time) error
Cancel(ctx context.Context, reservationID string) error
}For the MVP, InMemoryFrequencyStore holds confirmed impressions and active reservations behind one mutex. Reserve prunes expired state, counts confirmed impressions plus active reservations in the rolling window, and records a reservation in the same critical section. This prevents cap oversubscription among goroutines in the single process.
These fields show different kinds of ad ID proof. They aren't interchangeable. In this project, all data are fake test data.
universal_ad_idis a source-independent creative ID. In this project, it's fake test data, not a true ACIF registry ID. UniversalAdId is the VAST element that gives an ad a single identity across every system that touches it. Ad servers, DSPs, SSPs, and verification vendors each assign their own internal creative IDs, but none match. The UniversalAdId is the one ID that means the same unique ad everywhere.source_creative_idis assigned by one demand source (Amazon APS, Google Ad Manager, Publica, FreeWheel...). Different sources can assign different values to the same ad, so it's never enough by itself to prove canonical identity.media_hashidentifies the exact file bytes of a video with a cryptographic hashing algorithm (likeSHA-256orMD5). Test values usesha256:-style strings. If 2 video files have equal hashes, they are byte-for-byte identical files. Different hashes may represent different transcodes (720p vs. 1080p) or remuxed files of the same ad.fixture_fingerprint: Audio/video fingerprinting creates unique mathematical signatures based on how media actually looks and sounds, not relying on file metadata or raw bits. This lets us ID ads even if their files are resized, compressed, or transcoded. Audio fingerprints could use Chromaprint. Video fingerprints could use VideoHash.
This resolver should use proof in descending order of trust:
universal_ad_id -> exact media hash -> fixture_fingerprint -> unknown. A source-local ID, advertiser, or product match does not mean 2 ads are the same.
The fixture resolver loads data/identity_registry.json, a separate trusted
mapping from opaque proof values to canonical IDs. It never derives an answer
from a candidate's identifier text, and data/ground_truth.json is evaluation
data only. This prevents the synthetic test data from leaking canonical IDs
through strings such as fingerprint-creative-003.
The current test fixtures keep the three identity signals separate. Check
universal_ad_id for trusted identifier proof, media_hash for exact-file
proof, and fixture_fingerprint for the simulated audiovisual-content
signal.
| Scenario | universal_ad_id |
media_hash |
fixture_fingerprint |
Same underlying ad? | Expected result |
|---|---|---|---|---|---|
| Exact same file | Same or trusted | Same | Same | Yes | Resolve to 1 canonical creative |
| Same file, different source IDs | Missing or different | Same | Same | Yes | Match on exact media hash |
| Same commercial, different transcodes | Missing | Different | Same | Yes | Match on fixture fingerprint |
| Different commercials, same product | Missing or different | Different | Different | No | Keep canonical creatives distinct |
| Different commercials, same advertiser | Missing or different | Different | Different | No | Same advertiser does NOT mean same ad |
| Unknown creative identity | Missing | Different | Missing | Unknown | Return unresolved/unknown |
For example, two Toyota RAV4 candidates can share product_id: "rav4" while
mapping to different canonical creative IDs. They must not be deduplicated
just because they advertise the same product. Conversely, two source-local
Toyota IDs with different transcodes could still be the same ad if their trusted universal ad IDs or fixture fingerprints match.
The 30-candidate fixture has 27 candidates with sufficient proof and three
explicitly expected-unknown candidates. Its Toyota scenario is four
candidates spanning two distinct RAV4 commercials: candidates 024/025 share
one fingerprint and map to creative-003; 026/027 share another and map to
creative-004. The two groups must remain distinct despite identical
advertiser and product metadata.
- Load the 30 fixed ad offers and the trusted registry used to match source-specific ad IDs to the same underlying commercial.
- Use the seed to create a repeatable viewing history: households, devices, channels, ad breaks, channel changes, and playback results.
- Give every break the same 30 ad offers. The selector uses the trusted registry, but never uses the evaluation-only answer key.
- Replay the exact same history once with the baseline rules and once with the optimized rules. Each run starts with its own empty exposure store plus the same synthetic prior viewing history.
- For each break, preserve protected inventory, fill a 120-second pod, and temporarily reserve each selected ad.
- At the scheduled simulated time, record each selected ad as confirmed, canceled, delayed, or failed to settle.
- After both replays, use the separate answer key to measure repeated commercials and household frequency-cap violations.
For one illustrative household, the simulator may create:
household-001household-001-device-01watchingchannel-02household-001-device-02watchingchannel-05- A break on device 01 at simulated 00:12, followed by a break on device 02 at simulated 00:25
- Four selected 30-second slots in each 120-second break
- A source-local offer such as
candidate-025and another offer such ascandidate-024that the trusted registry identifies as the same canonical commercial
The exact devices, channels, and times depend on the seed, but the IDs and ordering are always reproducible.
CPM is a bid rate, not per-impression revenue. Estimated gross revenue for a confirmed impression is CPM / 1000.
| Feature | Implementation |
|---|---|
| Synthetic catalog | Deterministic JSON candidates for multiple sources and canonical creatives |
| VAST fixtures | Simulated VAST 3.0 inline responses; no live ad calls |
| Creative resolution | Known Universal Ad ID, exact media hash, precomputed fingerprint, or unknown |
| Intra-pod deduplication | Avoid exact-creative duplicates when eligible alternatives exist |
| Household caps | Rolling-window state across simulated channels and devices |
| Atomic reservations | map + sync.Mutex, reservation TTLs, confirmation, and cancellation |
| Pod selection | Greedy CPM-aware selection subject to duration and inventory constraints |
| Reproducible simulator | Fixed seed, immutable event stream, isolated strategy replays |
| Playback lifecycle | Confirmed, canceled, delayed, and expired reservations |
| Metrics and tests | Settlement, fill, revenue, duplicate, cap, identity, and determinism metrics |
- Playback-aware completed-break state for rewind/replay.
- Product-level separation, language eligibility, and ad-safety metadata.
- Decision explainability and a lightweight viewing timeline.
- More complete VAST parsing, wrappers, tracking events, and policy validation.
- Decision budgets and deterministic degraded-mode behavior.
- Simulated UID2 and LiveRamp RampID integrations for consent-aware audience identity mapping and cross-device/household frequency-capping experiments.
- Real media processing with FFmpeg, audio fingerprints, and perceptual video hashes.
- VAST 4.x, OpenRTB pod bidding, and registry-backed creative-ID validation.
- Redis-backed frequency storage for shared atomic state across multiple service instances.
- Docker packaging, Kubernetes deployment, load testing, and fault injection.
- Event pipelines and advanced pod optimization.
adpod-lab/
βββ README.md
βββ data/{candidates,creatives,identity_registry,ground_truth}.json
βββ go.mod
βββ cmd/simulator/main.go
βββ internal/
β βββ creative/resolver.go
β βββ frequency/{store.go,memory.go}
β βββ model/
β βββ pod/selector.go
β βββ simulator/{simulator.go,simulator_test.go}
βββ cmd/generate-fixtures/main.go
βββ ..._test.go
There is no Dockerfile, Compose file, Redis client, or service dependency in the MVP path.
go test ./...
go test -race ./...
go run ./cmd/simulator \
-seed 20261011 \
-households 20 \
-devices 3 \
-channels 5 \
-minutes 120The default run creates 20 households, gives each household between one and
three devices, chooses among five fictional channels, and simulates two hours.
Use -households 100 to run the larger scenario. The seed controls the
generated viewing behavior and playback outcomes; the committed 30 candidate
fixtures remain the shared inventory for both strategies.
For example, if device 01 has already shown canonical commercial
creative-003, the baseline only records that exposure under device 01 and
the source-local ID it received. Device 02 can therefore select another
source's offer for the same commercial. The optimized run translates both
offers to creative-003, shares the exposure across the household, and can
reject the second offer when the rolling frequency limit is reached. It also
avoids putting two offers for the same recognized commercial in one pod.
The optimized run therefore adds two kinds of precision: better identity precision across ad sources, and better household-level exposure accounting. Those protections can reduce fill or revenue when no eligible alternative remains; the simulator reports that tradeoff instead of assuming an improvement.
AdPod Lab Simulator
Seed: 20261011 Households: 20 Devices: <=3 Channels: 5 Minutes: 120
Candidates: 30 Events: 1944
Metric baseline optimized
Processed breaks 361 361
Ad opportunities 1444 1444
Selected ads 1444 1332
Confirmed impressions 1222 1117
Canceled impressions 151 146
Delayed confirmations 62 58
Failed settlements (aggregate) 71 69
Incomplete pods 0 28
Intra-pod duplicates 226 0
Frequency-cap violations 626 0
Estimated revenue $48.8221 $41.4609
Confirmed revenue $41.1716 $34.6367
Fill rate 100.00% 92.24%
Unresolved identities 3 3
AdPod Lab Simulator: the local program that creates the viewing history, makes both ad decisions, and prints this comparison.Seed: the starting number for the repeatable scenario generator. The same seed and flags produce the same households, events, outcomes, and report.Households: the number of synthetic viewer homes created for the run.Devices: <=3: each household receives one, two, or three simulated devices; three is the maximum set by the-devicesflag.Channels: the number of fictional channels available to those devices.Minutes: the length of simulated viewing time. This is simulated time, so a 120-minute run completes quickly.Candidates: the number of ad offers available to every break. The candidate pool contains 30 offers from multiple simulated sources.Events: the generated viewing actions, ad-break requests, channel changes, and playback callbacks. They are created once and shared by both runs.Metric: one measured result. Thebaselineandoptimizedcolumns show the same result for the two rule sets.Processed breaks: the number of ad breaks that occurred in the generated viewing history.Ad opportunities: the number of available ad slots. Each 120-second break has four 30-second slots, so 361 breaks produce 1,444 opportunities.Selected ads: the number of offers placed into pods before playback is settled. A selected ad can later be canceled or fail to settle.Confirmed impressions: selected ads whose playback was successfully confirmed.Canceled impressions: selected ads canceled before confirmation because the simulated viewer left or changed channels.Delayed confirmations: confirmations that arrived later than the normal playback callback but still arrived before the reservation expired.Failed settlements (aggregate): selected ads whose later callback could not be applied, usually because the reservation expired or was unknown. The current store interface combines those causes.Incomplete pods: breaks where the rules could not fill all four slots.Intra-pod duplicates: repeated versions of the same underlying commercial within one pod. This is evaluated with the trusted ground-truth mapping, not only with the selector's prediction.Avoidable / protected duplicates: the report separates duplicates that could have been avoided from duplicates caused by protected inventory.Frequency-cap violations: confirmed impressions beyond the configured limit for one household and one commercial during the rolling time window. Each excess impression is counted once, not every possible pair.Estimated revenue: the potential gross value of every selected ad, calculated as its CPM divided by 1,000, whether or not it is confirmed.Confirmed revenue: the gross value of confirmed impressions only, using CPM divided by 1,000.Fill rate: the percentage of available slots that received a selected ad.Unresolved identities: candidate offers for which the trusted registry could not confidently identify the underlying commercial.
The baseline is the control experiment. It still safely reserves, confirms, and cancels ads, but it treats each device and each source's ad ID separately. It can therefore select two source offers that are actually the same commercial, and an exposure on device 01 does not affect device 02.
The optimized run uses the same lifecycle, but adds trusted cross-source identity matching, household-wide exposure history, rolling-window frequency caps, and avoidance of recognized duplicates within a pod. This makes the comparison fair: the optimized run is more precise about identity and household history, not simply better because the baseline was made unsafe.
The simulator reports actual tradeoffs; it does not hard-code an expected revenue or fill improvement.
- The in-memory store is correct only in one Go process and loses data on restart.
- Fixture fingerprints model resolution proof; they are not extracted from media.
- JSON fixtures are controlled input; VAST parsing and live integrations are intentionally out of scope.
- A greedy optimizer is intentionally simple and may not find the global optimum.
- Decision-latency percentiles are not part of the current compact report; the simulation prioritizes deterministic lifecycle and outcome metrics.
The local store is an intentional MVP boundary, not a claim of multi-instance correctness. In a horizontally scaled service, independent process-local maps could each admit exposure capacity for the same household. A future RedisFrequencyStore can preserve the interface while using shared atomic reservation operations. Docker becomes useful then for reproducibly running the service and Redis locally; Kubernetes and load tests follow once multiple instances are in scope.