SAG-AFTRA Residuals Logic: A Deterministic Python Engine for Reuse Payments
Residuals are the most volatile and legally sensitive component of production payroll. Unlike base compensation, which settles at wrap, residuals compound across distribution windows, platform shifts, and international territories long after principal photography ends — and a single misapplied streaming tier or stale rate stamp becomes exactly the variance a Screen Actors Guild – American Federation of Television and Radio Artists (SAG-AFTRA) auditor or a completion-bond examiner will ask a production to explain. Treating residuals as a spreadsheet exercise guarantees that exposure; treating them as a deterministic, event-driven engine removes it. This page specifies that engine — its inputs, its routing state machine, its production-grade Python, the collective bargaining agreement terms it must honor, and the quarantine and verification mechanics that make it defensible — as one subsystem of the broader Guild Compliance & Rule Validation Automation reference architecture.
Prerequisites and Expected Inputs
The implementation targets Python 3.11+, both for zoneinfo in the standard library and for modern union-type syntax. It leans on a deliberately small stack: Pydantic v2 for boundary schema validation via model_validate and field_validator, as documented in the Pydantic v2 reference; the standard-library decimal, hashlib, and zoneinfo modules for currency-safe arithmetic, deterministic audit hashing, and timezone-aware timestamps; and, at production scale, polars or SQLAlchemy to stream reuse events and persist an append-only ledger. Never represent money as float — as the Python decimal module documentation makes explicit, only fixed-point arithmetic gives you the deterministic rounding a trust-fund remittance requires; a fractional-cent drift, compounded across thousands of reuse events, is a reconciliation the guarantor will make you redo.
The engine assumes its inputs arrive already normalized. Reuse events do not originate here: heterogeneous distribution manifests, session-rate exports, and deal-memo parameters are cleaned upstream by the Cost Ingestion & Data Parsing Workflows subsystem, where Async Batch Processing absorbs distributor API rate limits and Schema Validation & Error Handling quarantines malformed payloads before they reach any rule engine. Each performer, production, and payment line is keyed against the taxonomy defined in Cost Code Standardization, so a validated reuse event maps to exactly one performer, one contract tier, and one budget line. The single input this engine consumes is a validated ResidualRecord — a performer, a distribution category, a base session rate, the applicable contractual minimum, the exhibition date, and the ratified agreement version in force on that date. Its output is an immutable ResidualLedgerEntry carrying the resolved residual, the pensionable base, and a SHA-256 fingerprint of the record that produced it.
Architecture: Residuals Are a Routing State Machine, Not One Formula
The common design error is to reach for a single residual formula. There is no single formula. SAG-AFTRA residuals are a strategy problem: each distribution channel maps to a distinct calculator governed by its own rate structure, exhibition thresholds, and fixed-residual pools. A production-ready engine therefore validates a record at the boundary, routes it by distribution category to the calculator that owns that channel, and emits an immutable ledger entry stamped with the rule version and the source hash — so that a year later, an auditor can reconstruct not just the number but the exact code path and agreement that produced it.
The diagram below shows how a validated record is routed by distribution category to its dedicated calculator before emitting an immutable ledger entry; the specific formulas are governed by the applicable agreement, and any category the router cannot resolve is quarantined rather than guessed.
Two properties make this model defensible rather than merely functional. First, routing is total: every category either resolves to a calculator or is quarantined, so a subscription-video-on-demand (SVOD) budget tier the engine has never seen never silently defaults to the wrong pool. Second, every calculator is a pure function — same record in, same ledger entry out, every time — which is what lets a production accountant replay a disputed quarter deterministically without duplicating a single accrual. When the primary distributor feed is delayed and a provisional accrual has to be booked, that provisional path is the conservative, over-accruing branch specified in Compliance Fallback Chains, later reconciled against verified exhibition data rather than treated as final.
Core Implementation
The reference engine models the reuse event as a frozen Pydantic v2 object validated at the boundary, dispatches through a calculator registry keyed by distribution category, and writes a hashed provenance entry for every resolved payment. Monetary fields are Decimal; audit timestamps are timezone-aware; the routing table is explicit so an unmapped category is quarantined, never coerced.
import hashlib
import json
import logging
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Any
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, Field, field_validator
logger = logging.getLogger("sag_aftra_residuals")
CENTS = Decimal("0.01")
# Audit timestamps are stamped in UTC; the production hub's IANA zone is used
# only to render human-facing reports — never a fixed offset.
AUDIT_TZ = ZoneInfo("America/Los_Angeles")
class DistributionCategory(str, Enum):
HIGH_BUDGET_SVOD = "high_budget_svod"
LOW_BUDGET_SVOD = "low_budget_svod"
AVOD = "avod"
SYNDICATION = "syndication"
class ResidualRecord(BaseModel):
"""Boundary schema for one reuse event entering the residuals engine."""
model_config = ConfigDict(frozen=True)
performer_id: str
production_id: str
category: DistributionCategory
base_session_rate: Decimal = Field(gt=Decimal("0"))
applicable_minimum: Decimal = Field(gt=Decimal("0"))
exhibition_date: str # ISO date the work was exhibited
cba_version: str # ratified agreement stamp, e.g. "2023.MOA"
pension_health_rate: Decimal = Field(ge=Decimal("0"))
@field_validator(
"base_session_rate", "applicable_minimum", "pension_health_rate",
mode="before",
)
@classmethod
def reject_float(cls, v: Any) -> Decimal:
# Reject float inputs outright; parse strings/ints exactly.
if isinstance(v, float):
raise ValueError("monetary fields must be str/Decimal, never float")
return Decimal(str(v))
class ResidualLedgerEntry(BaseModel):
model_config = ConfigDict(frozen=True)
performer_id: str
category: DistributionCategory
residual_base: Decimal
gross_residual: Decimal
pension_health_contribution: Decimal
cba_version: str
rule_version: str
payload_hash: str
computed_at: str
operator_id: str = "SYS_AUTO"
class ResidualCalculator:
"""Strategy interface. Each channel owns its own rate structure."""
rule_version: str = "illustrative-1"
def residual_base(self, rec: ResidualRecord) -> Decimal:
# The base is generally the greater of the negotiated session rate
# and the applicable contractual minimum for the tier.
return max(rec.base_session_rate, rec.applicable_minimum)
def gross_residual(self, rec: ResidualRecord) -> Decimal: # pragma: no cover
raise NotImplementedError
class HighBudgetSVODCalculator(ResidualCalculator):
# Illustrative apportionment factor. In production these derive from the
# ratified fixed-residual pool and budget-tier schedule, not a constant.
RATE_FACTOR = Decimal("0.65")
def gross_residual(self, rec: ResidualRecord) -> Decimal:
return (self.residual_base(rec) * self.RATE_FACTOR).quantize(
CENTS, rounding=ROUND_HALF_UP
)
class LowBudgetSVODCalculator(ResidualCalculator):
RATE_FACTOR = Decimal("0.35")
def gross_residual(self, rec: ResidualRecord) -> Decimal:
return (self.residual_base(rec) * self.RATE_FACTOR).quantize(
CENTS, rounding=ROUND_HALF_UP
)
class AVODCalculator(ResidualCalculator):
# Ad-supported distribution accrues against a distinct pool and factor.
RATE_FACTOR = Decimal("0.30")
def gross_residual(self, rec: ResidualRecord) -> Decimal:
return (self.residual_base(rec) * self.RATE_FACTOR).quantize(
CENTS, rounding=ROUND_HALF_UP
)
class SyndicationCalculator(ResidualCalculator):
# Syndication runs on a declining-percentage schedule keyed to the
# exhibition run; modeled here as a single representative step.
RATE_FACTOR = Decimal("0.40")
def gross_residual(self, rec: ResidualRecord) -> Decimal:
return (self.residual_base(rec) * self.RATE_FACTOR).quantize(
CENTS, rounding=ROUND_HALF_UP
)
CALCULATORS: dict[DistributionCategory, ResidualCalculator] = {
DistributionCategory.HIGH_BUDGET_SVOD: HighBudgetSVODCalculator(),
DistributionCategory.LOW_BUDGET_SVOD: LowBudgetSVODCalculator(),
DistributionCategory.AVOD: AVODCalculator(),
DistributionCategory.SYNDICATION: SyndicationCalculator(),
}
class ResidualsEngine:
def __init__(self) -> None:
self.ledger: list[ResidualLedgerEntry] = []
self.quarantine: list[dict] = []
def _hash(self, rec: ResidualRecord) -> str:
# Canonical serialization so identical records hash identically.
payload = json.dumps(
rec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def process(self, rec: ResidualRecord) -> ResidualLedgerEntry | None:
payload_hash = self._hash(rec)
calc = CALCULATORS.get(rec.category)
if calc is None:
self._quarantine(rec, payload_hash, "unmapped distribution category")
return None
base = calc.residual_base(rec)
gross = calc.gross_residual(rec)
# Pensionable earnings flow to the P&H engine, not this ledger's total.
ph = (gross * rec.pension_health_rate).quantize(
CENTS, rounding=ROUND_HALF_UP
)
entry = ResidualLedgerEntry(
performer_id=rec.performer_id,
category=rec.category,
residual_base=base,
gross_residual=gross,
pension_health_contribution=ph,
cba_version=rec.cba_version,
rule_version=calc.rule_version,
payload_hash=payload_hash,
computed_at=datetime.now(tz=ZoneInfo("UTC")).isoformat(),
)
self.ledger.append(entry)
logger.info(
"performer=%s category=%s gross=%s hash=%s",
entry.performer_id, entry.category.value,
entry.gross_residual, entry.payload_hash,
)
return entry
def _quarantine(self, rec: ResidualRecord, payload_hash: str, reason: str) -> None:
self.quarantine.append({
"payload": rec.model_dump(mode="json"),
"payload_hash": payload_hash,
"reason_code": reason,
"quarantined_at": datetime.now(tz=ZoneInfo("UTC")).isoformat(),
"requires_manual_override": True,
})
logger.warning("Quarantined %s: %s", rec.performer_id, reason)
The frozen=True config makes both the incoming record and the resolved ledger entry immutable once validated, so no downstream caller can mutate a rate after it has been hashed. Because serialization is canonical — sorted keys, no incidental whitespace — the SHA-256 fingerprint is stable: two runs that process the same record produce the same hash, and any tampering with the source payload changes it. That fingerprint is the spine of the whole audit story, and it is what lets the engine cross-reference Pension & Health Fund Calculations without double-counting, because the pensionable base is derived from the same immutable record rather than a re-keyed copy.
Guild and Contract Specifics
SAG-AFTRA residuals are governed by the applicable collective bargaining agreement (CBA) in force on the exhibition date, and the calculator a record routes to is only correct relative to that agreement’s rate structure. The engine models the differences explicitly rather than folding them into one branch:
- High-budget SVOD. Streaming residuals on high-budget subscription platforms accrue against a fixed residual pool apportioned by budget tier and, under recent agreements, by domestic subscriber count and viewership metrics. The calculator’s base is the greater of the negotiated session rate and the tier minimum; the apportionment factor is a versioned schedule input, never a hardcoded constant.
- Low-budget SVOD. Lower-budget streaming tiers carry a distinct, smaller pool and a different minimum floor. Routing a low-budget title through the high-budget calculator over-accrues; routing a high-budget title through the low-budget calculator under-accrues and shorts the fund — the second error is the one that surfaces as a delinquency.
- Ad-supported (AVOD). Ad-supported distribution accrues against its own pool on an ad-revenue-linked basis, which is why it is a separate calculator with a separate ledger lineage rather than a flag on the SVOD path.
- Syndication. Broadcast and cable syndication runs on a declining-percentage schedule keyed to the exhibition run, generating recurring obligations that span multiple fiscal years. The engine must produce deterministic forward schedules that factor in contractual holdbacks, withholdings, and distribution-waterfall priority.
Two cross-cutting realities shape all four. First, the rate table is versioned by ratified agreement: a CBA is a succession of memoranda of agreement, each governing a date range, so cba_version and exhibition_date are first-class fields — a snapshot from last cycle’s agreement must fail validation against a current-period reuse event, not silently reprice it. Second, residuals rarely arrive in jurisdictional isolation. When a shoot straddles guilds, coordinating residual accrual with the turnaround and overtime windows in DGA Overtime & Turnaround Rules requires synchronized time-window tracking so an overlapping production day is never counted twice against two jurisdictions.
Error Handling and Quarantine
Not every failure should resolve to a number. The rule mirrors the boundary discipline of the ingestion layer: routing handles category resolution; quarantine handles record validity. A record whose distribution category is unmapped, whose base_session_rate is non-positive, whose cba_version does not bracket the exhibition date, or which carries a float monetary field is not a candidate for any calculator — it is quarantined.
Every quarantine event serializes the original payload verbatim, attaches the SHA-256 hash of that payload, records a machine-readable reason_code, and pushes the record to a reconciliation queue for human triage. Crucially, the failing payload never enters the ledger as a resolved residual — it enters a separate exception store, so the residual ledger stays clean while the discrepancy is preserved for review. This is the same quarantine contract enforced in Schema Validation & Error Handling: a rejected record carries a reason code, its original bytes, and its hash, so a production accountant can triage without re-ingesting the whole batch. When streaming metrics or theatrical-gross thresholds are delayed rather than wrong, the engine does not quarantine — it books a conservative, over-accruing provisional through the tiered pathway in Compliance Fallback Chains and flags it for reconciliation once verified exhibition data lands.
Verification
A residuals engine is only trustworthy if you can prove, after the fact, which calculator and which agreement produced every accrual. Verification checks three artifacts, not one.
First, the ledger entry. Every resolved residual must produce exactly one ResidualLedgerEntry whose category names the calculator, whose rule_version and cba_version pin the logic and the agreement, whose payload_hash matches an independent re-hash of the source record, and whose monetary fields are Decimal. Re-processing the same record must yield an identical hash and an identical gross — the idempotency check that lets you replay a disputed quarter deterministically.
Second, the audit log fields. Each computation must log, at minimum: performer_id, production_id, category, residual_base, gross_residual, payload_hash, cba_version, rule_version, a UTC computed_at, and an operator_id (SYS_AUTO for automated runs, a real ID for manual overrides). A completion-bond auditor reads this sequence as the provenance of the number, so no field is optional.
Third, the reconciliation report shape. A period report should surface, per distribution category: the count and aggregate of residuals resolved, the pensionable base carried forward to the P&H engine, the value of any provisional accruals still awaiting verified exhibition data, and every quarantined record awaiting triage. A healthy run is fully resolved with a short, explained provisional tail; a run heavy with provisionals or quarantines is a signal that a distributor feed needs attention before it becomes a payroll problem. A minimal harness confirms the invariants:
def verify(entry: ResidualLedgerEntry, engine: ResidualsEngine,
rec: ResidualRecord) -> None:
replay = engine.process(rec)
assert replay is not None, "valid record must resolve to a ledger entry"
assert replay.payload_hash == entry.payload_hash, "non-deterministic hash"
assert replay.gross_residual == entry.gross_residual, "non-deterministic gross"
assert isinstance(entry.gross_residual, Decimal), "money must be Decimal"
assert entry.operator_id, "every entry needs an accountable operator id"
Engineered with strategy-based routing, Decimal-exact arithmetic, agreement-versioned rate tables, and conservative provisional defaults, a SAG-AFTRA residuals engine turns the most volatile line in production payroll into a controlled, auditable workflow: accruals do not drift, the completion guarantor’s scrutiny is satisfied, and every residual remains traceable to a signed, version-stamped source across every distribution window a title outlives.
Related
- Guild Compliance & Rule Validation Automation — the parent architecture that routes normalized reuse events through every compliance engine, including this one.
- Pension & Health Fund Calculations — where the pensionable base this engine derives becomes a fund remittance.
- Compliance Fallback Chains — the conservative provisional pathway used when distributor metrics are delayed rather than final.
- DGA Overtime & Turnaround Rules — the overlapping time-window structures residual accrual must synchronize against on multi-guild shoots.
- Schema Validation & Error Handling — the boundary and quarantine discipline this engine reuses for invalid records.