Calculating SVOD Residual Pools by Subscriber Tier
Under recent Screen Actors Guild – American Federation of Television and Radio Artists (SAG-AFTRA) agreements, a residual on a high-budget subscription-video-on-demand (SVOD) title is not a percentage of a rate card but a share of a fixed pool whose size steps with the platform’s domestic subscriber count — and the moment the reported subscriber number crosses a tier boundary, the distributable pool changes and every performer’s share moves with it. The narrow problem this page solves is that apportionment: given a version-stamped tier schedule, a base residual pool, a reported domestic subscriber count, and the cast unit weights for a title, resolve exactly one subscriber tier, scale the pool by that tier’s factor, and split the result across performers so the shares sum to the pool to the cent. It is the concrete pool-and-tier calculation that the high-budget SVOD calculator in the parent SAG-AFTRA Residuals Logic engine routes to, expanded into a defensible, replayable function.
Prerequisites and Context
This page extends the SAG-AFTRA Residuals Logic subsystem within the broader Guild Compliance & Rule Validation Automation reference architecture. It assumes a validated reuse event has already been routed here by distribution category — the routing, quarantine, and boundary discipline live in the parent engine — so this page owns only the tier resolution and the pool split. The reuse events themselves arrive normalized from the Cost Ingestion & Data Parsing Workflows subsystem, and each performer and payment line is keyed against the taxonomy in Cost Code Standardization.
The implementation targets Python 3.11+ and leans on a small stack: Pydantic v2 for boundary validation (model_validate, field_validator, ConfigDict(frozen=True)); and the standard-library decimal and hashlib modules for currency-safe arithmetic and deterministic audit hashing. Money is Decimal end to end — as the official Python decimal module documentation makes explicit, only fixed-point arithmetic under a defined rounding context reproduces the figure a trust fund and a completion guarantor will both independently recompute. Two contract realities shape the model. First, the tier schedule and the base pool are governed by the agreement in force on the exhibition date, so the schedule is version-stamped and selected by that date before this function runs. Second, the tier bands are half-open intervals: a subscriber count sits in exactly one tier, and the boundary belongs to precisely one side, because an off-by-one at a tier edge silently reprices the entire pool.
Step-by-Step Implementation
The calculation has three stages, each isolated so it can be tested and replayed independently. Model the tier bands and the pool as frozen Pydantic v2 types so nothing mutates a rate after validation. Resolve exactly one tier for the reported subscriber count using half-open intervals — zero or multiple matches is a schedule error, not a value to guess. Then scale the base pool by the tier factor and apportion it across the cast by unit weight, reconciling the final rounding cent so the shares sum to the distributable pool exactly.
from __future__ import annotations
import hashlib
import json
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from typing import Sequence
from pydantic import BaseModel, ConfigDict, field_validator
CENTS = Decimal("0.01")
class SubscriberTier(BaseModel):
"""One half-open subscriber band and the pool factor it unlocks."""
model_config = ConfigDict(frozen=True)
name: str
lower: int # inclusive lower bound on domestic subscribers
upper: int | None # exclusive upper bound; None = open-ended top tier
pool_factor: Decimal # fraction of the base residual pool distributed
@field_validator("pool_factor", mode="before")
@classmethod
def _no_float(cls, v):
if isinstance(v, float):
raise ValueError("pool_factor must be str or Decimal, never float")
return Decimal(str(v))
def contains(self, subscribers: int) -> bool:
# Half-open: [lower, upper). The boundary belongs to the upper tier.
if subscribers < self.lower:
return False
return self.upper is None or subscribers < self.upper
class PoolSchedule(BaseModel):
"""A version-stamped SVOD residual schedule: base pool plus tier bands."""
model_config = ConfigDict(frozen=True)
agreement_version: str
base_pool: Decimal
tiers: tuple[SubscriberTier, ...]
@field_validator("base_pool", mode="before")
@classmethod
def _pool_decimal(cls, v):
if isinstance(v, float):
raise ValueError("base_pool must be str or Decimal, never float")
return Decimal(str(v))
def tier_for(self, subscribers: int) -> SubscriberTier | None:
matches = [t for t in self.tiers if t.contains(subscribers)]
# Exactly one band must apply: zero is a gap, two is an overlap.
return matches[0] if len(matches) == 1 else None
class PerformerUnit(BaseModel):
model_config = ConfigDict(frozen=True)
performer_id: str
units: Decimal # apportionment weight, e.g. contractual unit share
@field_validator("units", mode="before")
@classmethod
def _units_decimal(cls, v):
if isinstance(v, float):
raise ValueError("units must be str or Decimal, never float")
return Decimal(str(v))
def apportion_svod_pool(
schedule: PoolSchedule,
subscribers: int,
cast: Sequence[PerformerUnit],
exhibition_date: date,
) -> dict:
tier = schedule.tier_for(subscribers)
if tier is None:
raise ValueError(f"no unique subscriber tier for {subscribers:,} subscribers")
distributable = (schedule.base_pool * tier.pool_factor).quantize(
CENTS, rounding=ROUND_HALF_UP
)
total_units = sum((p.units for p in cast), Decimal("0"))
if total_units <= 0:
raise ValueError("total apportionment units must be positive")
shares: list[dict] = []
allocated = Decimal("0.00")
for perf in cast:
share = (distributable * perf.units / total_units).quantize(
CENTS, rounding=ROUND_HALF_UP
)
allocated += share
shares.append({"performer_id": perf.performer_id,
"units": str(perf.units), "share": str(share)})
# Per-line rounding leaves a sub-cent drift; assign it to the last share so
# the parts sum to the distributable pool exactly — no penny created or lost.
drift = distributable - allocated
if drift and shares:
shares[-1]["share"] = str(Decimal(shares[-1]["share"]) + drift)
payload = {
"agreement_version": schedule.agreement_version,
"exhibition_date": exhibition_date.isoformat(),
"domestic_subscribers": subscribers,
"tier": tier.name,
"pool_factor": str(tier.pool_factor),
"base_pool": str(schedule.base_pool),
"distributable_pool": str(distributable),
"shares": shares,
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
return payload
Two properties make the result defensible rather than merely plausible. The drift reconciliation guarantees the per-performer shares sum back to the distributable pool exactly; without it, quantizing each share independently leaves a stray cent that reads to a trust-fund auditor as a pool that does not balance. And because the schedule is frozen and version-stamped, the agreement_version on the payload names the exact tier structure the pool was scaled by — the figure is reproducible from the archived record long after the subscriber count that drove it has changed.
The illustrative schedule below shows how a reported subscriber count selects one half-open band, and how the tier factor scales the base pool into the distributable amount that is then split across the cast.
Audit Trail Requirements
Every apportionment must serialize to a write-once store so a SAG-AFTRA examiner or a completion-bond auditor can reconstruct not just each performer’s share but the pool it came from. At minimum the persisted payload carries: the agreement_version that governs the tier structure; the exhibition_date that selected that version; the domestic_subscribers count reported, and the tier and pool_factor it resolved to; the base_pool and the distributable_pool after scaling; the full shares array with each performer_id, its units, and its resolved share; and the payload_sha256 fingerprint of the canonical payload. Recording both the raw subscriber count and the resolved tier is deliberate — it lets an auditor confirm the boundary was applied correctly at the exact number that drove the pool, which is where tier disputes concentrate.
Persist each record to append-only, write-once storage — object storage under an immutability lock, or an event-sourced table with no in-place update — before any residual accrual is booked downstream, so a crash mid-run leaves a replayable record of intent rather than a silent gap. A restated subscriber count or a corrected schedule is a new compensating record, never an edit of the posted one, so the pool a residual accrual was built on stays recoverable. That recoverability is why the number matters beyond payroll: a distributable pool and its per-performer shares feed the residual accrual that Completion Bond Reporting & Guarantor Analytics folds into a title’s cost-to-complete, and a guarantor will not release reserves against an accrual it cannot trace to a version-stamped source.
Gotchas and Production Edge Cases
Tier boundary edges. The single most expensive bug here is an off-by-one at a tier edge. If bands are modeled as closed intervals on both sides, a subscriber count that lands exactly on a boundary matches two tiers and the pool doubles or the resolution becomes non-deterministic; if there is a one-count gap between bands, a count in the gap matches nothing. Half-open intervals [lower, upper) are the fix — every integer count belongs to exactly one band, and the boundary value belongs unambiguously to the upper tier, as the contains method encodes. Requiring exactly one matching band turns any residual gap or overlap in the schedule into a caught error rather than a silently mispriced pool.
Versioning by exhibition date. A tier schedule is only correct relative to the agreement in force on the exhibition date. A snapshot from last cycle’s agreement applied to a current-period exhibition silently reprices the pool against the wrong tier structure. Select the PoolSchedule by exhibition date before calling the apportionment, keep agreement_version as a first-class field on the output, and let a schedule whose date range does not bracket the exhibition fail resolution rather than quietly repricing. This mirrors the effective-dating discipline the parent SAG-AFTRA Residuals Logic engine enforces across every distribution category.
The rounding cent must land somewhere deterministic. Quantizing each performer’s share independently almost always leaves the sum a cent or two off the distributable pool. Dropping that residual makes the pool fail to balance; distributing it arbitrarily makes the run non-reproducible. Assigning the drift to a single deterministic recipient — the last share here — keeps the parts summing to the whole and keeps two runs identical. For a larger cast a formal largest-remainder allocation is the same idea generalized; the invariant is that the reconciliation rule is fixed, not incidental.
Float contamination. Never build a Decimal from a float — a pool_factor of 0.8 read as a binary float carries rounding error straight into a six-figure pool. Read every factor, pool, and unit weight through its string form, as Decimal(str(...)) above, and reject float at the boundary outright, so a fractional cent never compounds into the variance a guarantor asks you to explain. Idempotent replay depends on it too: because the payload hash is deterministic, re-running an apportionment after a partial failure is safe only if the consumer deduplicates on (agreement_version, exhibition_date, payload_sha256).
Related
- SAG-AFTRA Residuals Logic — the parent engine that routes a validated reuse event to the high-budget SVOD calculator this page implements.
- Pension & Health Fund Calculations — where the pensionable base derived from a residual becomes a fund remittance.
- Compliance Fallback Chains — the conservative provisional path used when a subscriber count or tier schedule is delayed rather than final.
- Completion Bond Reporting & Guarantor Analytics — where residual accruals from this pool feed a title’s cost-to-complete and the guarantor’s reserve analysis.
- Cost Code Standardization — the taxonomy that keys every performer and payment line to exactly one budget line.
Up one level: SAG-AFTRA Residuals Logic, part of Guild Compliance & Rule Validation Automation.