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 | None union syntax, dict[str, ...] typing, and timezone-aware daypart boundary math used below.
  • Pydantic v2 — pin pydantic==2.7.1; the config models rely on v2 field_validator and model_validator semantics to reject contradictory rules at construction.
  • A config source — a YAML or JSON file under version control (pyyaml==6.0.1 if YAML). The policy is reviewed and diffed like code, so a separation change carries an approving reviewer in its history.
  • The category taxonomy — the ProductCategory tree from Enforcing Competitive Separation Rules, so every rule’s category_code resolves 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’s starts_at maps 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.

yaml
# 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.

python
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.

python
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.

python
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.

python
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.

Daypart and category resolution to a separation window A spot's air time is mapped to exactly one daypart using the configured calendar; an air time matching no part fails loudly as a calendar gap. With the daypart fixed, the resolver looks up the (category, daypart) cell in the rule index. If no rule exists at the spot's own category, it climbs the taxonomy tree to the parent group and looks again, so a wireless_carrier spot inherits the telco rule unless a carrier-specific rule overrides it. The first rule found yields a resolved window with a scope and gap; reaching the tree root with no rule means the category is unrestricted in that daypart. Spot starts_at timezone-aware UTC Resolve daypart calendar lookup ERR_DAYPART_GAP no matching part · fail loud Rule at category? no Climb to parent group wireless_carrier → telco re-check parent cell yes Resolved window scope + min_gap_ms root reached → unrestricted

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.

python
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 late part running 23:00–02:00 cannot be expressed as one start < end interval. Split it into two boundaries — 23:00–24:00 and 00:00–02:00 sharing the same name — so the DaypartBoundary validator stays satisfied and resolve_daypart matches 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 to prime and never to daytime. Confirm the shared boundary times match to the second across adjacent parts, or a spot on the seam raises ERR_DAYPART_GAP despite the calendar looking complete.
  • Contradictory scope and gap. A rolling rule with min_gap_seconds: 0 forbids nothing yet reads as an active rule, and a same_break rule with a large gap implies a measured window it never enforces. The _coherent_gap validator 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.