Detecting As-Run vs Scheduled Discrepancies

This guide solves one exact task: given a scheduled log and an as-run log for a single channel and broadcast day, deterministically match each aired event back to the spot it was meant to be and classify the outcome — aired-as-scheduled, shifted, short, long, preempted, or missing — using explicit start-time and duration tolerances rather than exact equality. It is the detection procedure that sits under As-Run Reconciliation and Discrepancy Handling, itself a subsystem of Broadcast Traffic Architecture & Taxonomy. Getting the tolerances right is not cosmetic: set them too tight and every normal airing floods the traffic desk as a false discrepancy; set them too loose and a spot that genuinely ran short slips through and gets billed at full rate, which is exactly the kind of overstatement an FCC public-file or SOC 2 revenue audit is designed to catch.

The core requirement is that classification be reproducible. The same two logs, re-run tomorrow or replayed from cold storage during a billing dispute, must yield the identical set of discrepancies — so the matcher holds no hidden state and every decision is a pure function of the inputs and a declared tolerance policy. Those classifications become the credits and make-good candidates assembled in generating discrepancy reports for billing, so a mistake here propagates straight to the invoice.

Prerequisites

  • Python 3.11+ — required for the X | None union syntax, match statements, and timezone-aware datetime.now(timezone.utc) used below.
  • Pydantic v2 — pin exactly (pydantic==2.7.1); the classifier relies on v2 field validators to reject naive datetimes at the boundary.
  • Both logs normalized upstream — the scheduled and as-run streams must already carry canonical house_number, isci, and UTC timestamps per the canonical traffic field data dictionary, so this pass measures deviation, not field structure.
  • A declared tolerance policy — start-time and duration tolerances held in config, owned by traffic managers, versioned so a change that re-classifies historical airings is auditable rather than silent.
  • Read-only access to both logs — a mounted export or a SELECT-scoped DB role; detection must never mutate the schedule or the as-run telemetry it reads.
  • Preemption context attached — as-run events displaced by an EAS emergency interrupt or a network break should carry a preemption_reason so the classifier can separate a deliberate preemption from a genuine miss.

Step-by-Step Implementation

Detection runs as a pure pipeline over each scheduled spot: derive a match key, find the nearest unconsumed as-run event, measure start-time drift and duration delta, then apply the tolerance policy to land on exactly one DiscrepancyType. The state machine below is the whole decision surface.

Discrepancy classification decision tree A scheduled spot enters the classifier. First test: was a matching as-run event found. If not, and a sibling event carried a preemption reason, classify PREEMPTED, otherwise MISSING; both route to make-good. If a match was found, test whether the airing was verified and non-zero: if not, classify MISSING. Then test the duration delta against the duration tolerance: if the airing was materially under booked length classify SHORT with pro-rata billing, if materially over classify LONG billed at booked length. Otherwise test start-time drift against the start tolerance: beyond tolerance is SHIFTED, within tolerance is AIRED_AS_SCHEDULED. SHIFTED, LONG, and AIRED_AS_SCHEDULED bill at full rate. Scheduled spot Match found? no PREEMPTED / MISSING preemption_reason? → make-good yes Verified, non-zero? no MISSING yes |delta| > dur tol? yes SHORT / LONG SHORT bills pro-rata |drift| > start tol? no SHIFTED yes AIRED_AS _SCHEDULED no

Figure — The classification decision tree: match, then verify, then test duration delta against tolerance, then test start drift against tolerance, landing on exactly one discrepancy type per scheduled spot.

Step 1 — Model the tolerance policy and the inputs

Goal: fix the typed records and the declared tolerance policy so classification is driven by configuration, not constants. The policy is versioned because changing it re-classifies historical airings, and that must be an auditable event.

python
from __future__ import annotations

import logging
from datetime import datetime, timezone
from enum import Enum

from pydantic import BaseModel, Field, field_validator

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.reconcile.detect")


class DiscrepancyType(str, Enum):
    AIRED_AS_SCHEDULED = "aired_as_scheduled"
    SHIFTED = "shifted"
    SHORT = "short"
    LONG = "long"
    PREEMPTED = "preempted"
    MISSING = "missing"


class TolerancePolicy(BaseModel):
    policy_version: str = "2026.07"          # bump when tolerances change
    start_tolerance_ms: int = 2_000          # drift within this is not a shift
    duration_tolerance_pct: float = Field(default=0.05, gt=0, lt=1)

    def duration_tolerance_ms(self, booked_ms: int) -> int:
        # A fraction of booked length: a :30 tolerates more absolute drift
        # than a :10, matching how encoders round longer creatives.
        return int(booked_ms * self.duration_tolerance_pct)


class ScheduledSpot(BaseModel):
    spot_id: str
    house_number: str
    scheduled_at: datetime
    expected_duration_ms: int = Field(gt=0)
    preemptible: bool = True

    @field_validator("scheduled_at")
    @classmethod
    def _utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("scheduled_at must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)


class AsRunEntry(BaseModel):
    event_id: str
    house_number: str
    aired_at: datetime
    actual_duration_ms: int = Field(ge=0)
    verified: bool = True
    preemption_reason: str | None = None

    @field_validator("aired_at")
    @classmethod
    def _utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("aired_at must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)

Expected result: importing the module logs nothing yet, but constructing an AsRunEntry with a naive aired_at raises ValueError: aired_at must be timezone-aware (UTC) — the fail-closed guard that keeps DST math off naive datetimes.

Step 2 — Match a scheduled spot to its as-run event

Goal: derive the deterministic key and select the nearest unconsumed as-run event. Matching keys on house_number and broadcast day, then breaks ties by proximity, so a uniformly shifted pod still matches spot-for-spot.

python
def broadcast_key(house_number: str, when: datetime) -> str:
    # House number scoped to the UTC broadcast date the traffic system assigned.
    return f"{house_number}:{when.date().isoformat()}"


def find_match(
    spot: ScheduledSpot, pool: list[AsRunEntry]
) -> AsRunEntry | None:
    key = broadcast_key(spot.house_number, spot.scheduled_at)
    candidates = [e for e in pool
                  if broadcast_key(e.house_number, e.aired_at) == key]
    if not candidates:
        return None
    # Nearest airing to the scheduled time wins; identical distances -> earliest.
    return min(candidates, key=lambda e: (
        abs((e.aired_at - spot.scheduled_at).total_seconds()), e.aired_at))

Expected result: for a spot scheduled at 18:37:00Z with two same-house airings at 18:37:01Z and 18:52:10Z, find_match returns the 18:37:01Z event — the four-second-drift airing, not the later reairing.

Step 3 — Measure drift and delta, then classify against tolerance

Goal: compute signed start drift and duration delta and land on exactly one DiscrepancyType. Duration is tested before start time, so a spot that is both late and truncated bills as SHORT, not merely SHIFTED.

python
def classify(
    spot: ScheduledSpot, entry: AsRunEntry, policy: TolerancePolicy
) -> DiscrepancyType:
    start_drift_ms = int(
        (entry.aired_at - spot.scheduled_at).total_seconds() * 1000)
    duration_delta_ms = entry.actual_duration_ms - spot.expected_duration_ms
    dur_tol = policy.duration_tolerance_ms(spot.expected_duration_ms)

    if entry.actual_duration_ms == 0 or not entry.verified:
        result = DiscrepancyType.MISSING          # logged frames absent
    elif duration_delta_ms < -dur_tol:
        result = DiscrepancyType.SHORT            # pro-rata credit downstream
    elif duration_delta_ms > dur_tol:
        result = DiscrepancyType.LONG             # bill at booked length
    elif abs(start_drift_ms) > policy.start_tolerance_ms:
        result = DiscrepancyType.SHIFTED
    else:
        result = DiscrepancyType.AIRED_AS_SCHEDULED

    logger.info("%s | %s | drift=%dms delta=%dms",
                spot.spot_id, result.value, start_drift_ms, duration_delta_ms)
    return result

Expected log line: 2026-07-17T05:12:44+00:00 | INFO | traffic.reconcile.detect | SP-2026-0716-0442 | short | drift=1400ms delta=-4000ms — a spot that aired 1.4s late and 4s under its booked thirty, classified SHORT.

Step 4 — Classify the unmatched spot as preempted or missing

Goal: distinguish a spot displaced by a higher-priority event from one that simply never aired. A preemption carries different contractual weight even though both route to make-good.

python
def classify_unmatched(
    spot: ScheduledSpot, as_run: list[AsRunEntry]
) -> DiscrepancyType:
    # PREEMPTED only when a sibling event that day carried a preemption reason,
    # evidence the pod was displaced rather than dropped by a trafficking error.
    day = spot.scheduled_at.date()
    displaced = any(e.preemption_reason and e.aired_at.date() == day
                    for e in as_run)
    result = (DiscrepancyType.PREEMPTED
              if spot.preemptible and displaced
              else DiscrepancyType.MISSING)
    logger.warning("%s | %s | no as-run match | make-good candidate",
                   spot.spot_id, result.value)
    return result

Expected log line: 2026-07-17T05:12:45+00:00 | WARNING | traffic.reconcile.detect | SP-2026-0716-0451 | preempted | no as-run match | make-good candidate when an EAS event sits in the same day, otherwise missing.

Verification & Testing

Correct behaviour rests on two properties: tolerance sensitivity (a drift on the boundary lands on the right side of it) and determinism (the same inputs always produce the same class). Both are assertable against fixture data drawn from a real broadcast day.

python
from datetime import timedelta

POLICY = TolerancePolicy(start_tolerance_ms=2_000, duration_tolerance_pct=0.05)
BASE = datetime(2026, 7, 16, 18, 37, tzinfo=timezone.utc)

spot = ScheduledSpot(spot_id="SP-1", house_number="H7-QSR-030",
                     scheduled_at=BASE, expected_duration_ms=30_000)

# 1. Within both tolerances -> AIRED_AS_SCHEDULED (1.5s drift, +500ms delta).
ok = AsRunEntry(event_id="AR-1", house_number="H7-QSR-030",
                aired_at=BASE + timedelta(milliseconds=1_500),
                actual_duration_ms=30_500)
assert classify(spot, ok, POLICY) is DiscrepancyType.AIRED_AS_SCHEDULED

# 2. Duration short beyond the 5% (1500ms) band -> SHORT, not SHIFTED.
short = AsRunEntry(event_id="AR-2", house_number="H7-QSR-030",
                   aired_at=BASE + timedelta(seconds=9),
                   actual_duration_ms=26_000)
assert classify(spot, short, POLICY) is DiscrepancyType.SHORT

# 3. Start drift past 2s but full duration -> SHIFTED.
late = AsRunEntry(event_id="AR-3", house_number="H7-QSR-030",
                  aired_at=BASE + timedelta(seconds=6),
                  actual_duration_ms=30_000)
assert classify(spot, late, POLICY) is DiscrepancyType.SHIFTED

# 4. No airing, EAS sibling present -> PREEMPTED.
eas = AsRunEntry(event_id="AR-EAS", house_number="EAS-CH7",
                 aired_at=BASE, actual_duration_ms=45_000,
                 preemption_reason="eas_activation")
assert classify_unmatched(spot, [eas]) is DiscrepancyType.PREEMPTED

Because classify is pure, its output for a fixed policy is stable across machines and runs — replaying an archived day during an audit reproduces the identical discrepancy set. Run the suite in CI against a golden fixture of scheduled/as-run pairs so a change to a tolerance that would silently re-classify last quarter’s airings fails the build instead of shipping.

Edge Cases & Failure Handling

  • Systematic clock skew. If one source system’s clock is a few seconds off UTC, every spot in the day drifts by the same amount and a whole daypart classifies as SHIFTED. Detect it by watching for a near-constant start_drift_ms across unrelated spots; the fix is to re-sync NTP and re-run, never to widen start_tolerance_ms, which would also swallow genuine shifts. The broadcast_key join still holds under skew, so no spot is lost — only mis-labelled — which is why re-running after the correction is safe.
  • Duplicate house numbers in one day. A creative booked several times per day produces several as-run events under one key. Consume each matched event exactly once (remove it from the pool after a match) so a second scheduled airing claims the second airing rather than double-matching the first. If the as-run log is genuinely missing one airing, the surplus scheduled spot correctly falls through to MISSING.
  • Verified-but-zero airing. Playout can log an event with verified=True yet actual_duration_ms == 0 when it cued a spot that never rolled frames. The classifier treats zero duration as MISSING regardless of the verified flag, because a spot that emitted no video did not air — billing it as SHORT would invoice nothing-for-something. Route these to the same make-good queue used for make-good routing for preemptions.

FAQ

Why match on house number instead of the traffic system's spot ID?

Because the as-run log almost never echoes the traffic system’s spot_id — playout and trafficking are usually different vendors with different primary keys. The house_number is the station-stable creative identifier both systems agree on, so keying on it scoped to the broadcast day gives a deterministic join. This mirrors the identity discipline in the canonical traffic field data dictionary: compute identity from stable business attributes, never inherit one system’s surrogate key.

How should I set the start-time and duration tolerances?

Start from the noise floor of your own plant: measure the natural drift distribution over a clean week and set start_tolerance_ms just above the bulk of it (two to three seconds is typical), so ordinary encoder and switcher latency does not register as a shift. Keep the duration tolerance as a percentage of booked length rather than an absolute, so a :30 and a :10 are judged proportionally. Version the policy and treat any change as auditable, because loosening a tolerance can convert a SHORT into a full-rate airing after the fact.

What is the difference between a preempted spot and a missing one?

Both have no as-run event, but a PREEMPTED spot was displaced by a higher-priority event — an EAS emergency interrupt or a network break — while a MISSING spot simply never aired, usually a trafficking or playout fault. The classifier prefers PREEMPTED only when a sibling event that day carries a preemption_reason. The distinction matters contractually: a preemption is often a make-good with a clear cause, whereas a bare miss is a service failure the station has to explain.

Why test duration before start-time drift?

Because a spot can be both late and truncated, and the truncation is the more consequential fact for billing. If start drift were tested first, a spot that aired six seconds late and four seconds short would be labelled SHIFTED and billed at full rate, hiding the under-delivery. Testing the duration delta first means it lands as SHORT and bills pro-rata, which is what flows into generating discrepancy reports for billing.