DGA Overtime & Turnaround Rules: A Timezone-Aware Python Validation Engine
The Directors Guild of America (DGA) enforces a mandatory rest window between a director’s wrap and their next scheduled call, and a shortfall in that window converts silently into penalty compensation that lands on the daily cost report whether or not anyone caught it. The engineering problem this section solves is narrow and unforgiving: given a stream of wrap and call timestamps that arrive in mixed formats, mixed timezones, and across shooting days that may span a Daylight Saving Time boundary, compute the turnaround shortfall deterministically, classify the penalty tier against the applicable agreement, and emit an audit record a completion guarantor can recompute months later. This engine is one of the deterministic checks that sits inside Guild Compliance & Rule Validation Automation; it assumes clean, cost-coded input and focuses entirely on turnaround math and the audit trail around it.
Prerequisites
This engine targets Python 3.11 or newer, because it relies on the standard-library zoneinfo module for IANA timezone resolution and on graphlib.TopologicalSorter for ordering dependent checks. Monetary arithmetic uses the decimal module exclusively — floating point is never used for a penalty amount. Input parsing and validation use Pydantic v2 (model_validate, field_validator); batch alignment of large timecard exports uses polars for vectorized timestamp handling; and persistence of the audit ledger assumes a write-once store fronted by SQLAlchemy. The expected inputs are already-normalized timecard and call-sheet records: each carries a director or assistant-director identifier, a wrap instant, the next scheduled call instant, and a shoot-location timezone. Those records reach this engine already parsed and typed by the upstream Cost Ingestion & Data Parsing Workflows subsystem, where Async Batch Processing absorbs payroll-API rate limits and Schema Validation & Error Handling rejects malformed payloads before they can reach a rule engine. Each hour this engine validates is keyed to exactly one fund code and one budget line through Cost Code Standardization, so a penalty accrual maps cleanly onto the ledger.
Architecture: From Raw Timestamps to a Classified Penalty
The validation path is a single deterministic pipeline. A record is normalized to a shoot-location timezone, the turnaround delta is computed as a pure function of two timezone-aware instants, the shortfall is measured against the required window, and the result is classified into a penalty tier drawn from the applicable rate table. Every branch of that classification — including the no-violation branch — writes to the audit ledger, so an examiner sees an unbroken record rather than only the exceptions.
The diagram below traces how a measured turnaround window resolves into a tiered penalty classification, with each threshold treated as an agreement-governed rule parameter rather than fixed law.
Core Implementation
The DGA Basic Agreement establishes a mandatory minimum rest period — commonly a 10-hour turnaround — between a director’s wrap and the next scheduled call. When that window is not honored, the agreement provides for penalty compensation, and many productions model the exposure as a tiered schedule keyed to the size of the shortfall, with larger shortfalls escalating toward a full additional day’s compensation. Because the precise penalty terms vary by agreement, tier, and amendment, the engine treats the specific thresholds and rates as configurable rule parameters rather than hardcoded law. Translating any such schedule into code requires precise datetime arithmetic, explicit timezone handling, and careful isolation of overlapping meal-penalty windows.
Validation begins at the boundary. A Pydantic v2 model rejects any timestamp that is not timezone-aware, because a naive datetime is the single largest source of silent turnaround miscalculation. Only a record that survives the boundary reaches the calculation.
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
from pydantic import BaseModel, field_validator, model_validator
class TurnaroundRecord(BaseModel):
"""A validated wrap/next-call pair for one guild member on one shoot day."""
member_id: str
wrap_time: datetime
next_call_time: datetime
location_tz: str = "America/Los_Angeles"
@field_validator("wrap_time", "next_call_time")
@classmethod
def _must_be_aware(cls, value: datetime) -> datetime:
if value.tzinfo is None:
raise ValueError("timestamps must be timezone-aware (attach a zoneinfo tz)")
return value
@model_validator(mode="after")
def _call_after_wrap(self) -> "TurnaroundRecord":
if self.next_call_time <= self.wrap_time:
raise ValueError("next_call_time must be after wrap_time")
return self
With a validated record in hand, the turnaround calculation itself is a pure function: same inputs, same outputs, no global state and no mutable side effects. It normalizes both instants to the shoot-location timezone, measures the elapsed window, and classifies the shortfall against agreement-supplied thresholds.
def calculate_turnaround_violation(
wrap_time: datetime,
next_call_time: datetime,
timezone: str = "America/Los_Angeles",
required_turnaround_hours: float = 10.0,
) -> dict:
"""Validate a DGA turnaround window and classify the penalty exposure.
Expects timezone-aware datetimes and returns a deterministic penalty
mapping. Penalty tiers are illustrative parameters; the authoritative
thresholds should come from the applicable agreement's rate tables.
"""
if wrap_time.tzinfo is None or next_call_time.tzinfo is None:
raise ValueError("wrap_time and next_call_time must be timezone-aware.")
tz = ZoneInfo(timezone)
wrap = wrap_time.astimezone(tz)
call = next_call_time.astimezone(tz)
if call <= wrap:
raise ValueError("Next call time must be after wrap time.")
turnaround_hours = (call - wrap).total_seconds() / 3600
shortfall = max(0.0, required_turnaround_hours - turnaround_hours)
if shortfall == 0.0:
return {"violation": False, "shortfall_hours": 0.0, "penalty_type": "none"}
if shortfall <= 1.0:
penalty = "tier_1_straight_time"
elif shortfall <= 2.0:
penalty = "tier_2_straight_time"
else:
penalty = "full_day_plus_ot"
return {
"violation": True,
"shortfall_hours": round(shortfall, 2),
"penalty_type": penalty,
"actual_turnaround": round(turnaround_hours, 2),
}
The turnaround delta is arithmetic on hours, so ordinary float rounding for the human-readable shortfall is acceptable. The moment that shortfall becomes money, however, the calculation must move into Decimal. A separate valuation step maps the classified penalty_type to a payable amount using the member’s contractual day rate, quantized to cents under an explicit rounding context — never a binary float, whose representation error compounds across a payroll batch until totals no longer reconcile.
from decimal import Decimal, ROUND_HALF_UP
PENALTY_MULTIPLIERS = {
"none": Decimal("0"),
"tier_1_straight_time": Decimal("0.5"),
"tier_2_straight_time": Decimal("1.0"),
"full_day_plus_ot": Decimal("1.5"),
}
def value_penalty(penalty_type: str, day_rate: Decimal) -> Decimal:
"""Convert a classified penalty tier into a payable amount in dollars."""
multiplier = PENALTY_MULTIPLIERS[penalty_type]
amount = day_rate * multiplier
return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Because the shortfall classifier and the valuation step are separate pure functions, the engine can be unit-tested against the agreement’s rate table without ever touching a live payroll system, and the same code path runs identically inside an Async Batch Processing worker as it does in a single interactive reconciliation.
Guild & Contract Specifics
The controlling document is the DGA Basic Agreement, supplemented by the Freelance Live and Tape Television Agreement for episodic work; the exact turnaround minimum and penalty schedule depend on which agreement and which amendment cycle a production falls under. Three parameters drive the engine and belong in a versioned rate table, not in code: the required turnaround window (commonly ten hours for a director on a distant location, with shorter windows negotiated for certain assignments), the tier thresholds that separate a partial-hour shortfall from a full forced-call penalty, and the day-rate basis the multiplier is applied against. A forced call — requiring a member to report before the rest window has fully elapsed — is the highest-cost outcome and typically resolves to the full_day_plus_ot tier, because the production owes an additional day’s compensation rather than a partial premium.
The rate table itself is best modeled as an effective-dated structure: each row carries an agreement identifier, an effective-date range, the required-turnaround hours, an ordered list of (shortfall_ceiling, multiplier) tiers, and the day-rate reference. Resolving the correct row for a given shoot day is a lookup by agreement and date, which keeps the engine correct across mid-season amendments without a code change. When a member’s schedule triggers a turnaround penalty, the adjusted gross must cascade into downstream fund math: the penalty premium raises pensionable earnings, so the result feeds Pension & Health Fund Calculations rather than terminating at the cost report. Base pay and penalty adjustments must also be logged before any secondary-compensation pass runs, so that SAG-AFTRA Residuals Logic computes against a settled compensation base rather than a figure still in flight.
Error Handling & Quarantine
Real production data does not arrive clean, and the engine’s defining behavior is that it never silently overwrites human-entered time data. A record that fails boundary validation — a naive timestamp, a next call at or before wrap, an unknown location timezone, or a missing day-rate reference — is not dropped and not guessed. It is quarantined: routed to an exception queue with the original payload preserved intact for forensic review, exactly the routing that Compliance Fallback Chains standardizes across the compliance engines. When the applicable rate table cannot be resolved for a shoot day, the engine follows the same conservative principle it uses everywhere: it over-accrues against the nearest known tier rather than under-accruing, and flags the record for manual reconciliation.
Every quarantined and every processed record carries a SHA-256 hash computed over a canonical serialization of the input payload, the resolved rule version, and the calculated output. That hash is what makes the record tamper-evident: an examiner can recompute it months later and confirm nothing was altered after the fact.
import hashlib
import json
def audit_hash(payload: dict, rule_version: str, output: dict) -> str:
"""Deterministic SHA-256 over the canonical (input, rule version, output) triple."""
canonical = json.dumps(
{"payload": payload, "rule_version": rule_version, "output": output},
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Because the hash is computed over a sorted, separator-normalized serialization, the same logical record always produces the same digest regardless of key order or whitespace — which is what lets the ledger deduplicate re-processed batches and detect a changed input on re-ingestion.
Verification
Confirming the engine is correct means checking three artifacts, not just the returned dict. First, the audit ledger: every input record — violation or not — must produce exactly one ledger row carrying the member identifier, the wrap and call instants in their normalized timezone, the measured turnaround hours, the classified penalty type, the Decimal penalty amount, the resolved rule version, and the SHA-256 hash. A missing row for a clean record is itself a defect, because gaps in the ledger read to an examiner as suppressed exceptions. Second, the reconciliation report: summing the penalty amounts by fund code must equal the accruals posted to the daily cost report to the cent, which is the check a completion guarantor performs. Third, the exception queue: every quarantined record must be recoverable in its original form, and re-running the engine over a corrected record must produce a new hash while leaving the original quarantine entry immutable.
A focused regression suite pins the boundaries that break in production: a wrap and call that straddle a spring-forward DST transition (the wall-clock gap and the true elapsed gap differ by an hour), a member who works two distant locations in different timezones on consecutive days, and an exactly-at-threshold shortfall that must land in the lower tier deterministically. The dedicated walkthrough of validating the DGA 10-hour turnaround rule in Python works those edge cases through end to end, from zoneinfo normalization to the forced-call penalty branch.
Frequently Asked Questions
Why must the turnaround calculation use timezone-aware datetimes instead of a simple hour count?
Call sheets list local shoot times while payroll systems expect a hub timezone, and a shoot day can cross a Daylight Saving Time boundary where the wall-clock gap and the true elapsed gap differ by an hour. Anchoring both instants to an IANA timezone via zoneinfo before subtracting is the only way the shortfall matches what the guild will compute.
Why keep the penalty thresholds in a rate table instead of in code? The required turnaround window and the tier multipliers vary by agreement, assignment, and amendment cycle. Modeling them as effective-dated rate-table rows lets the engine resolve the correct figures by agreement and shoot date, so a mid-season amendment is a data change rather than a code deploy.
Why is Decimal required when the turnaround delta is measured in hours?
The hours delta is fine as a float, but the instant a shortfall becomes a payable amount the arithmetic must move to Decimal under an explicit ROUND_HALF_UP context. Binary floating point cannot represent most cents exactly, and the error compounds across a batch until the penalty accruals no longer reconcile to the cost report.
What happens when a record has a naive timestamp or an unresolvable rate table? It is quarantined, not dropped or guessed. The original payload is preserved in an exception queue for manual reconciliation, the engine over-accrues against the nearest known tier rather than under-accruing, and a SHA-256 hash records the exact state for audit.
How does a turnaround penalty affect other guild calculations? The penalty premium raises the member’s gross, so it feeds pensionable-earnings math downstream and must be logged before any residual pass runs. That ordering keeps pension, health, and residual figures computing against a settled compensation base rather than a number still in flight.
Related
- Compliance Fallback Chains — the deterministic routing that quarantines a turnaround record when its rate table can’t be resolved.
- Pension & Health Fund Calculations — where a turnaround penalty’s premium raises pensionable earnings downstream.
- SAG-AFTRA Residuals Logic — secondary compensation that must compute against a settled base after penalties are logged.
- Validating DGA 10-Hour Turnaround Rules in Python — the end-to-end walkthrough of the edge cases this engine has to survive.
- Cost Ingestion & Data Parsing Workflows — the upstream pipelines that deliver clean, cost-coded wrap and call records to this engine.