How to Map ISCI and Ad-ID Codes to House Numbers
Every commercial that airs carries at least two identities that a traffic system must reconcile: the industry code the agency ships (a legacy 8-character ISCI slug or a modern 11–12 character Ad-ID), and the station’s own house number — the short internal key the automation, log, and billing systems actually schedule against. This guide solves one exact task: building a deterministic crosswalk that resolves an incoming ISCI or Ad-ID to a single, stable house number, with explicit collision handling so two different creatives can never collapse onto the same slot. It is the mapping procedure beneath Standardizing Billing Codes Across Traffic Systems, itself part of Broadcast Traffic Architecture & Taxonomy. Getting it wrong is not cosmetic: the house number is the join key that ties an as-run line back to an invoice, so a mapping that drifts or duplicates misbills the advertiser and leaves a public-inspection and SOC 2 trail that cannot be reproduced under audit.
The core requirement is that the crosswalk be deterministic and one-directional in resolution but reversible in lineage. The same Ad-ID presented tomorrow — possibly with different casing, a stray space, or a check-character variant — must resolve to the same house number, and that house number must always name exactly one creative. That rules out sequence counters that hand out the next free number by arrival order, in favour of a lookup-first, mint-deterministically-on-miss strategy validated against the canonical field contract in the traffic field data dictionary before anything is written downstream.
Prerequisites
- Python 3.11+ — required for the
X | Noneunion syntax,enum.StrEnum, and timezone-awaredatetime.now(timezone.utc)used throughout. - Pydantic v2 — pin
pydantic==2.7.1; the crosswalk models usefield_validatorandmodel_validatorto enforce ISCI and Ad-ID shape at the boundary. - A persistent house-number ledger — a table or key-value store that survives restarts, so mappings are stable across runs. Pin your driver exactly (
psycopg[binary]==3.2.1orredis==5.0.4); an in-memory dict is fine only for tests. - Read access to the agency traffic instructions — the source of the ISCI/Ad-ID, delivered as a mounted export or an API pull scoped to read-only.
- Upstream normalization applied — billing codes standardized per Standardizing Billing Codes Across Traffic Systems so the crosswalk resolves identity, not field structure.
- A house-number namespace — a station or market prefix (for example
WXYZ) held in config so two stations never mint colliding house numbers from the same Ad-ID.
Step-by-Step Implementation
The crosswalk runs as a pure boundary function per creative: normalize the incoming code, classify it as ISCI or Ad-ID, look it up in the ledger, and either return the existing house number or mint a deterministic one — refusing to overwrite an entry that already binds a different creative. A collision is never silently resolved; it is surfaced as an explicit COLLISION outcome for the traffic desk.
Figure — Crosswalk flow: an ISCI or Ad-ID is normalized, classified, and looked up; a hit returns the stable house number, a miss mints one deterministically only after a collision check clears the slot, and a clash is queued rather than overwritten.
Step 1 — Structured audit logging and typed models
Goal: emit greppable audit lines in the traffic-ops timestamp | level | module | spot_id shape and fix the models that carry a creative through the crosswalk. The audit log is the compliance artifact — every binding decision must be reconstructable from it alone.
from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime, timezone
from enum import StrEnum
from pydantic import BaseModel, field_validator
# Traffic-ops structured logging: timestamp | level | module | message(spot_id=...)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.taxonomy.house_number_crosswalk")
# ISCI: exactly 8 alphanumerics (legacy 4-char advertiser prefix + 4-char code).
# Ad-ID: 11 or 12 chars, 4-char registered prefix + 7 or 8 alphanumerics,
# optional trailing HD marker "H". Hyphens in display form are cosmetic.
ISCI_RE = re.compile(r"^[A-Z0-9]{8}$")
AD_ID_RE = re.compile(r"^[A-Z0-9]{11,12}$")
class CodeKind(StrEnum):
ISCI = "isci"
AD_ID = "ad_id"
class ResolveStatus(StrEnum):
RESOLVED = "resolved" # existing house number returned
MINTED = "minted" # new deterministic house number created
COLLISION = "collision" # target slot already bound to a different creative
REJECTED = "rejected" # code failed ISCI/Ad-ID shape validation
class CreativeCode(BaseModel):
raw_code: str
advertiser: str | None = None
@field_validator("raw_code")
@classmethod
def _shape(cls, v: str) -> str:
# Normalize before matching so casing, spaces and display hyphens converge.
cleaned = v.strip().upper().replace("-", "").replace(" ", "")
if not (ISCI_RE.match(cleaned) or AD_ID_RE.match(cleaned)):
raise ValueError(f"'{v}' is neither a valid ISCI nor Ad-ID")
return cleaned
class HouseMapping(BaseModel):
house_number: str
canonical_code: str
kind: CodeKind
status: ResolveStatus
mapped_at: str
Step 2 — Normalize and classify the incoming code
Goal: collapse casing, whitespace, and display hyphens into one canonical string, then decide whether it is an 8-character ISCI or an 11–12 character Ad-ID. Classification drives collision rules, because an ISCI and an Ad-ID for the same creative must resolve to one house number, not two.
def classify(canonical_code: str) -> CodeKind:
# An 8-char code is a legacy ISCI; 11-12 chars is a modern Ad-ID.
if ISCI_RE.match(canonical_code):
return CodeKind.ISCI
if AD_ID_RE.match(canonical_code):
return CodeKind.AD_ID
# Unreachable once CreativeCode validation has passed, but fail closed.
raise ValueError(f"unclassifiable code '{canonical_code}'")
Because CreativeCode already normalizes in its validator, classify operates on clean input. The same discipline is applied one layer up in Validating Field Constraints in Python, which enforces the dictionary-level rules the crosswalk assumes are already met.
Step 3 — Mint a deterministic house number
Goal: derive a house number that is a pure function of the namespace and canonical code, so the same creative always mints the same number even if the ledger is rebuilt from scratch.
def mint_house_number(canonical_code: str, namespace: str) -> str:
# Deterministic: hash (namespace, canonical_code) into a zero-padded slot.
# Re-running against the same inputs yields a byte-identical house number.
digest = hashlib.sha256(f"{namespace}:{canonical_code}".encode()).hexdigest()
slot = int(digest, 16) % 10_000_000 # 7-digit house-number space
house_number = f"{namespace}-{slot:07d}"
logger.debug("minted %s for code=%s", house_number, canonical_code)
return house_number
Step 4 — Resolve against the ledger with collision handling
Goal: compose the crosswalk. Look up the canonical code; on a hit return the stored house number, on a miss mint one and persist it — but refuse to bind a house number that already names a different creative.
class HouseNumberCrosswalk:
def __init__(self, namespace: str, ledger: dict[str, str] | None = None) -> None:
self.namespace = namespace
# code -> house_number, and the reverse binding for collision checks.
self._by_code: dict[str, str] = ledger or {}
self._by_house: dict[str, str] = {}
for code, house in self._by_code.items():
self._by_house[house] = code
def resolve(self, creative: CreativeCode) -> HouseMapping:
now = datetime.now(timezone.utc).isoformat()
code = creative.raw_code # already canonical post-validation
kind = classify(code)
existing = self._by_code.get(code)
if existing is not None:
logger.info("%s | resolved code=%s house=%s", code, code, existing)
return HouseMapping(house_number=existing, canonical_code=code,
kind=kind, status=ResolveStatus.RESOLVED, mapped_at=now)
house = mint_house_number(code, self.namespace)
bound_to = self._by_house.get(house)
if bound_to is not None and bound_to != code:
# Hash slot already holds a DIFFERENT creative — never overwrite.
logger.error("%s | COLLISION house=%s already bound to %s",
code, house, bound_to)
return HouseMapping(house_number=house, canonical_code=code,
kind=kind, status=ResolveStatus.COLLISION, mapped_at=now)
self._by_code[code] = house
self._by_house[house] = code
logger.info("%s | minted house=%s kind=%s", code, house, kind)
return HouseMapping(house_number=house, canonical_code=code,
kind=kind, status=ResolveStatus.MINTED, mapped_at=now)
A representative log line reads 2026-07-17T14:22:07+00:00 | INFO | traffic.taxonomy.house_number_crosswalk | ABCD1234000H | minted house=WXYZ-0043118 kind=ad_id.
Verification & Testing
Correctness rests on two properties: determinism (a code always resolves to the same house number) and collision safety (a slot already bound to a different creative is never overwritten). Both are assertable against fixture data drawn from a real agency instruction.
xwalk = HouseNumberCrosswalk(namespace="WXYZ")
# 1. Determinism across casing, spaces, and display hyphens.
a = xwalk.resolve(CreativeCode(raw_code="ABCD-1234-000H"))
b = xwalk.resolve(CreativeCode(raw_code=" abcd1234000h "))
assert a.status is ResolveStatus.MINTED
assert b.status is ResolveStatus.RESOLVED # second call hits the ledger
assert a.house_number == b.house_number # same creative, one house number
# 2. ISCI and Ad-ID classify distinctly.
isci = xwalk.resolve(CreativeCode(raw_code="ABCD1234"))
assert isci.kind is CodeKind.ISCI
assert a.kind is CodeKind.AD_ID
# 3. Malformed codes fail closed at the model boundary.
try:
CreativeCode(raw_code="XYZ") # too short for either format
assert False, "should have raised"
except ValueError:
pass
Run the suite in CI against a golden fixture of code → house-number pairs so any change to the normalization or minting rules that would silently re-key inventory fails the build instead of shipping. Because mint_house_number is pure, replaying an archived instruction during an audit reproduces byte-identical house numbers.
Edge Cases & Failure Handling
- ISCI and Ad-ID for one creative. A campaign often ships a legacy ISCI early and a registered Ad-ID later for the same film. Treat them as aliases: bind both codes to one house number by seeding the ledger with an alias map so the second code resolves rather than mints. Never let an alias mint a second house number, or the as-run splits across two invoices.
- Hash-slot collision. Two distinct canonical codes can hash to the same 7-digit slot. The reverse-binding check catches this and returns
COLLISIONinstead of overwriting; the operator assigns the loser a house number from a reserved manual band. Widening the slot space reduces the probability but never eliminates it, so the check is mandatory, not optional. - Malformed or truncated code. A code that survives transport but violates ISCI/Ad-ID shape is rejected at the
CreativeCodeboundary, not hashed into a garbage house number. This is usually an upstream normalization gap; apply billing code normalization before the crosswalk rather than loosening the validator, which would let unbillable creatives into the log.
FAQ
Why not just hand out the next sequential house number?
Because a sequence counter encodes arrival order, not identity, so re-ingesting the same instruction mints a second house number and splits the as-run across two invoices. A deterministic hash of the namespace and canonical code always yields the same house number for the same creative, which is the property audit replay depends on. The same idempotency reasoning underpins How to Map Legacy Spot IDs to Modern Schemas.
How do I tell an ISCI from an Ad-ID reliably?
Length after normalization is the discriminator: a legacy ISCI is exactly 8 alphanumerics, while an Ad-ID is 11 or 12 (a 4-character registered prefix plus 7–8 characters, optionally an HD marker). The classify function encodes this, and the field-level constraints it relies on are defined in the traffic field data dictionary. Never infer the kind from the hyphen pattern in the display form — hyphens are cosmetic and vary by supplier.
What happens when two creatives collide on the same house number?
The reverse-binding check returns a COLLISION status and logs the incumbent code rather than overwriting the slot, so no billing lineage is ever silently rebound. The traffic desk assigns the second creative a number from a reserved manual band. Retain the collision log line for audit; it is the evidence that the binding was resolved deliberately, which a billing reconciliation review will expect to see.
Should the crosswalk validate the whole record or just the code?
Just the code and its house-number binding. Resolve the rest of the record — durations, dayparts, sponsor IDs — against the full contract using the Pydantic validator pattern. Keeping identity resolution and full-record validation as separate passes means each fails for exactly one reason, which makes the collision or rejection cause unambiguous.
Related
- Standardizing Billing Codes Across Traffic Systems — the normalization that must run upstream so the crosswalk resolves clean ISCI and Ad-ID inputs.
- Canonical Traffic Field Data Dictionary — the field contract that defines the house-number, isci, and ad_id constraints this mapping enforces.
- How to Map Legacy Spot IDs to Modern Schemas — the sibling deterministic-identity procedure this crosswalk mirrors for creative codes.