Configuring Competitive Separation Windows by Daypart
A single separation number never survives contact with a real broadcast day. Two auto dealers four minutes apart is fine at 3 a.m. and a breached contract at 8 p.m., because prime inventory is scarcer, more valuable, and more closely watched. Competitive separation windows have to vary by daypart — and by category within each daypart — which means the policy belongs in declarative configuration owned by traffic managers, not hard-coded in the engine. This guide solves one exact task: authoring a per-category, per-daypart separation policy as data, loading and validating it so a malformed or contradictory rule fails on load rather than at air, and applying the resolved window at schedule time. It is the configuration procedure that sits under Enforcing Competitive Separation Rules, itself a subsystem of Spot Scheduling Validation & Rule Engines. Getting it right is not cosmetic: a rule that loads silently but never matches leaves everyone believing separation is enforced when it is not, and that gap is exactly what an advertiser’s reconciliation eventually bills back.
The windows configured here are the numbers the placement-time engine resolves for each candidate spot and the same policy the full-log conflict scan reads when it sweeps a finished log. Because both gates load one config, a window a traffic manager edits at noon governs both placement and verification by the next scheduling run.
Prerequisites
- Python 3.11+ — required for the
X | Noneunion syntax,dict[str, ...]typing, and timezone-aware daypart boundary math used below. - Pydantic v2 — pin
pydantic==2.7.1; the config models rely on v2field_validatorandmodel_validatorsemantics to reject contradictory rules at construction. - A config source — a YAML or JSON file under version control (
pyyaml==6.0.1if YAML). The policy is reviewed and diffed like code, so a separation change carries an approving reviewer in its history. - The category taxonomy — the
ProductCategorytree from Enforcing Competitive Separation Rules, so every rule’scategory_coderesolves to a real node and vendor labels are mapped to canonical codes via the spot schema first. - Agreed daypart boundaries — a station-wide definition of
overnight,daytime,prime, and any custom parts, in the broadcast timezone, so a spot’sstarts_atmaps to exactly one daypart.
Step-by-Step Implementation
The policy is authored as a table of (category, daypart) -> window entries, loaded into validated Pydantic models, cross-checked against the taxonomy and the daypart calendar, and then queried at schedule time by resolving a spot’s daypart and walking the category tree for the tightest applicable rule.
Step 1 — Author the policy as declarative data
Goal: express the whole separation policy as reviewable configuration — one entry per category per daypart, with the window scope and gap explicit. Nothing about a competitive group lives in code; the file below is the single source of truth a traffic manager edits.
# separation_policy.yaml — reviewed and diffed like code.
dayparts:
overnight: { start: "00:00", end: "06:00" }
daytime: { start: "06:00", end: "18:00" }
prime: { start: "18:00", end: "23:00" }
late: { start: "23:00", end: "24:00" }
rules:
# Autos: tight in prime, relaxed overnight.
- { category: auto, daypart: prime, scope: rolling, min_gap_seconds: 360 }
- { category: auto, daypart: daytime, scope: rolling, min_gap_seconds: 240 }
- { category: auto, daypart: overnight, scope: rolling, min_gap_seconds: 120 }
# QSR: never two in one break, any daypart.
- { category: qsr, daypart: prime, scope: same_break, min_gap_seconds: 0 }
- { category: qsr, daypart: daytime, scope: rolling, min_gap_seconds: 180 }
# Telco parent rule cascades to carriers unless overridden below.
- { category: telco, daypart: prime, scope: rolling, min_gap_seconds: 240 }
- { category: wireless_carrier, daypart: prime, scope: rolling, min_gap_seconds: 360 }
# Pharma sole-sponsorship in prime: at most one airing in the whole daypart.
- { category: pharma, daypart: prime, scope: daypart, min_gap_seconds: 0 }
Step 2 — Model the config with Pydantic v2
Goal: parse each row into a typed, self-validating model so a malformed window is rejected at load, not discovered at air. Structured logging uses the traffic-ops timestamp | level | module | spot_id shape; on config load the module field carries the config path rather than a spot.
from __future__ import annotations
import logging
from datetime import time
from enum import Enum
from pydantic import BaseModel, Field, field_validator, model_validator
# Traffic-ops structured logging: timestamp | level | module | spot_id
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.separation.config")
def _log(level: int, spot_id: str, msg: str) -> None:
logger.log(level, "%s | %s", spot_id, msg)
class WindowScope(str, Enum):
SAME_BREAK = "same_break" # No two of the category in one break
ROLLING = "rolling" # Minimum elapsed gap between airings
DAYPART = "daypart" # At most one airing in the whole daypart
class DaypartBoundary(BaseModel):
name: str
start: time # Broadcast-local start, inclusive
end: time # Broadcast-local end, exclusive
@model_validator(mode="after")
def _ordered(self) -> "DaypartBoundary":
# A daypart that ends before it starts would swallow the wrong spots.
# "24:00" is modelled as time.max upstream; equal start/end is empty.
if self.start >= self.end:
raise ValueError(f"daypart '{self.name}' start must precede end")
return self
class SeparationRuleConfig(BaseModel):
category: str
daypart: str
scope: WindowScope
min_gap_seconds: int = Field(ge=0)
@field_validator("category")
@classmethod
def _lower(cls, v: str) -> str:
return v.lower()
@model_validator(mode="after")
def _coherent_gap(self) -> "SeparationRuleConfig":
# A rolling window with a zero gap forbids nothing — almost always an
# authoring slip. same_break and daypart scopes legitimately use 0.
if self.scope is WindowScope.ROLLING and self.min_gap_seconds == 0:
raise ValueError(
f"rolling rule for {self.category}/{self.daypart} has zero gap"
)
return self
@property
def min_gap_ms(self) -> int:
return self.min_gap_seconds * 1000
class SeparationPolicy(BaseModel):
dayparts: list[DaypartBoundary]
rules: list[SeparationRuleConfig]
Step 3 — Load, then validate against the taxonomy and calendar
Goal: fail closed on the two silent-killer errors — a rule for a category the taxonomy does not know, and two rules competing for the same (category, daypart) cell. Both load without a syntax error yet corrupt enforcement, so they must be caught explicitly.
def load_policy(
raw: dict, known_categories: set[str]
) -> SeparationPolicy:
policy = SeparationPolicy.model_validate(raw) # Pydantic type-checks rows.
daypart_names = {d.name for d in policy.dayparts}
seen: set[tuple[str, str]] = set()
for rule in policy.rules:
# 1. Orphan rule: category not in the taxonomy — would never match.
if rule.category not in known_categories:
raise ValueError(
f"ERR_ORPHAN_RULE: '{rule.category}' not in taxonomy"
)
# 2. Unknown daypart: rule keyed to a part the calendar never emits.
if rule.daypart not in daypart_names:
raise ValueError(
f"ERR_UNKNOWN_DAYPART: '{rule.daypart}' not in calendar"
)
# 3. Duplicate cell: two rules for one (category, daypart) is ambiguous.
key = (rule.category, rule.daypart)
if key in seen:
raise ValueError(f"ERR_RULE_DUPLICATE: {key} defined twice")
seen.add(key)
logger.info("separation policy loaded | %d dayparts | %d rules",
len(policy.dayparts), len(policy.rules))
return policy
Expected log line on a clean load: 2026-07-17T21:40:02+00:00 | INFO | traffic.separation.config | separation policy loaded | 4 dayparts | 8 rules.
Step 4 — Resolve a spot’s daypart from its air time
Goal: map a timezone-aware starts_at to exactly one daypart name using the configured calendar, so the correct window is chosen. A spot must resolve to one part and only one; an unresolved air time is a calendar gap, not a default.
from datetime import datetime
def resolve_daypart(starts_at: datetime, policy: SeparationPolicy) -> str:
# Compare the broadcast-local wall-clock time against each boundary.
# starts_at is timezone-aware; the caller localises to broadcast time first.
t = starts_at.timetz().replace(tzinfo=None)
for boundary in policy.dayparts:
if boundary.start <= t < boundary.end:
return boundary.name
# No matching part means the calendar has a hole — fail loudly, never guess.
raise ValueError(f"ERR_DAYPART_GAP: {t} matches no configured daypart")
Step 5 — Query the tightest applicable window at schedule time
Goal: given a spot’s category and daypart, return the window the engine enforces, cascading up the category tree so a wireless_carrier inherits the telco rule when no carrier-specific rule exists — but a carrier rule, when present, wins.
class ResolvedWindow(BaseModel):
rule_id: str
category_code: str
daypart: str
scope: WindowScope
min_gap_ms: int
def build_index(
policy: SeparationPolicy,
) -> dict[tuple[str, str], SeparationRuleConfig]:
# (category, daypart) -> rule, built once and reused across the run.
return {(r.category, r.daypart): r for r in policy.rules}
def resolve_window(
category_code: str,
daypart: str,
index: dict[tuple[str, str], SeparationRuleConfig],
parent_of: dict[str, str | None],
) -> ResolvedWindow | None:
# Walk from the exact category up the taxonomy until a rule matches.
current: str | None = category_code.lower()
while current is not None:
rule = index.get((current, daypart))
if rule is not None:
_log(logging.INFO, category_code,
f"resolved {rule.scope.value} window {rule.min_gap_seconds}s "
f"via {current}/{daypart}")
return ResolvedWindow(
rule_id=f"RULE-{current.upper()}-{daypart.upper()}",
category_code=current,
daypart=daypart,
scope=rule.scope,
min_gap_ms=rule.min_gap_ms,
)
current = parent_of.get(current) # Climb to the parent group.
# No rule anywhere in the ancestry means this category is unrestricted here.
_log(logging.INFO, category_code, f"no rule for {daypart} | unrestricted")
return None
At schedule time the engine calls resolve_daypart then resolve_window, and hands the resolved min_gap_ms and scope to the clearance check described in Enforcing Competitive Separation Rules. A resolved-window log line reads 2026-07-17T21:41:18+00:00 | INFO | traffic.separation.config | wireless_carrier | resolved rolling window 360s via wireless_carrier/prime.
Figure — Window resolution: a spot’s air time fixes its daypart, then the resolver walks the category tree upward until a rule matches, inheriting the parent group’s window unless a narrower rule overrides it.
Verification & Testing
Correct behaviour rests on two properties: fail-closed loading (a contradictory or orphan rule is rejected on load, never at air) and correct cascade (a child category inherits its parent’s window unless overridden). Both are assertable against a fixture policy.
KNOWN = {"auto", "qsr", "telco", "wireless_carrier", "pharma"}
PARENT_OF = {"wireless_carrier": "telco", "telco": None, "auto": None,
"qsr": None, "pharma": None}
RAW = {
"dayparts": [
{"name": "daytime", "start": "06:00", "end": "18:00"},
{"name": "prime", "start": "18:00", "end": "23:00"},
],
"rules": [
{"category": "telco", "daypart": "prime", "scope": "rolling", "min_gap_seconds": 240},
{"category": "wireless_carrier", "daypart": "prime", "scope": "rolling", "min_gap_seconds": 360},
{"category": "pharma", "daypart": "prime", "scope": "daypart", "min_gap_seconds": 0},
],
}
policy = load_policy(RAW, KNOWN)
index = build_index(policy)
# 1. Override wins: a carrier uses its own 360s rule, not the telco 240s parent.
w = resolve_window("wireless_carrier", "prime", index, PARENT_OF)
assert w is not None and w.min_gap_ms == 360_000
assert w.category_code == "wireless_carrier"
# 2. Cascade: a telco with no carrier match still resolves the parent rule.
w2 = resolve_window("telco", "prime", index, PARENT_OF)
assert w2 is not None and w2.min_gap_ms == 240_000
# 3. Fail-closed: an orphan rule is rejected on load, never reaches resolution.
try:
load_policy({"dayparts": RAW["dayparts"],
"rules": [{"category": "crypto", "daypart": "prime",
"scope": "rolling", "min_gap_seconds": 300}]}, KNOWN)
raise AssertionError("orphan rule should have failed to load")
except ValueError as exc:
assert "ERR_ORPHAN_RULE" in str(exc)
Because loading is a pure function of the config and the taxonomy, a policy that passes in CI passes identically in production — the same fixture drives both. Run the suite against a golden policy so a change that would silently orphan a rule, or introduce a duplicate (category, daypart) cell, fails the build instead of shipping a hole in enforcement to air.
Edge Cases & Failure Handling
- Daypart boundary spanning midnight. A
latepart running 23:00–02:00 cannot be expressed as onestart < endinterval. Split it into two boundaries —23:00–24:00and00:00–02:00sharing the samename— so theDaypartBoundaryvalidator stays satisfied andresolve_daypartmatches either half to the same part. Never widen the validator to accept a wraparound; the split keeps each interval unambiguous. - Spot exactly on a boundary. A spot at precisely 18:00:00 must land in one daypart, not both. The comparison is start-inclusive and end-exclusive (
start <= t < end), so 18:00 belongs toprimeand never todaytime. Confirm the shared boundary times match to the second across adjacent parts, or a spot on the seam raisesERR_DAYPART_GAPdespite the calendar looking complete. - Contradictory scope and gap. A
rollingrule withmin_gap_seconds: 0forbids nothing yet reads as an active rule, and asame_breakrule with a large gap implies a measured window it never enforces. The_coherent_gapvalidator rejects the zero-gap rolling case on load; treat a same-break rule’s gap as advisory only. Author these deliberately, because the placement engine trusts the scope to decide how the gap is read.
FAQ
Why put separation windows in config instead of the engine code?
Because the policy changes far more often than the enforcement logic, and it is owned by traffic managers, not developers. A new sponsor tier, a merged advertiser, or a client demanding tighter prime separation is a config edit reviewed and diffed like code — no deploy, no engineering ticket. The placement engine and the full-log scan both read the one file, so a single edit governs placement and verification together.
How do child categories inherit a parent's separation rule?
The resolver walks up the taxonomy tree. When a spot’s exact (category, daypart) cell has no rule, it climbs to the parent group and looks again, so a wireless_carrier spot inherits the telco window unless a carrier-specific rule overrides it. This lets you write one broad rule for a group and only add narrower rules where a stricter contract demands them, keeping the policy small and the intent legible. The tree itself is defined in Enforcing Competitive Separation Rules.
What stops a bad rule from silently disabling separation?
The load step in Step 3 fails closed on the two silent killers: a rule whose category is not in the taxonomy (ERR_ORPHAN_RULE) and two rules competing for one (category, daypart) cell (ERR_RULE_DUPLICATE). Both parse without a syntax error yet corrupt enforcement, so they are caught explicitly and the load raises rather than returning a policy with a quiet hole. This is the same fail-on-config discipline used when tuning thresholds for scheduling accuracy.
How does a category-exclusive sponsorship fit this model?
Express it as a daypart-scope rule: at most one airing of the category in the whole daypart, which is sole-occupancy in practice. The pharma prime rule in Step 1 is exactly this. The engine then treats any second airing in that daypart as a breach regardless of the gap between them, which is the strictest window the model offers and the right shape for a sponsor who has bought category exclusivity. A recovered spot from make-good routing must respect it too.
Related
- Enforcing Competitive Separation Rules — the parent guide whose engine resolves and enforces the windows this configuration defines.
- Detecting Product-Category Conflicts in Commercial Breaks — the full-log scan that reads the same policy to verify a finished log against these windows.
- Building Rule Engines for Spot Rotation — the rotation engine that shares this declarative config pattern so rotation and separation never diverge on category logic.