Pension & Health Fund Calculations: Deterministic Fringe Automation for Union Payroll

Pension and health (P&H) contributions are among the most legally sensitive and financially volatile line items in any film or television budget. They are not flat percentages of gross pay: they scale with compensation thresholds, jurisdictional mandates, and work classification, and each guild’s trust fund enforces a contribution schedule that changes with every collective bargaining cycle. A miscalculated fringe accrual does not stay hidden — it surfaces as an audit flag from a union trust fund, a held payment, or a completion guarantor refusing to release a bond until the remittance discrepancy is reconciled. Spreadsheet-driven approximation cannot meet that bar, because a floating-point cent that drifts across thousands of contribution lines becomes exactly the variance an auditor asks you to explain. This page specifies a deterministic, code-enforced P&H calculation engine — its inputs, its architecture, its production Python, the collective bargaining agreements it must honor, and the quarantine and verification mechanics that make every number 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 through model_validate and field_validator; the standard-library decimal, hashlib, and zoneinfo modules for currency-safe arithmetic, deterministic audit hashing, and timezone-aware work dates; and, in production, a signature library such as cryptography to verify that a cached trust schedule was ratified before it is applied to a contribution. Never use float for monetary values — as the official Python decimal module documentation makes explicit, only an explicit precision and rounding context guarantees the deterministic output a trust fund’s remittance specification demands.

The engine assumes its inputs are already clean. P&H records do not originate here: heterogeneous timecards, deal-memo parameters, and payroll exports are normalized upstream by the Cost Ingestion & Data Parsing Workflows subsystem, where Async Batch Processing absorbs vendor API rate limits and Schema Validation & Error Handling quarantines malformed payloads before they ever reach a rule engine. Each hour is keyed against the taxonomy defined in Cost Code Standardization, so a validated line maps to exactly one fund code, one department, and one budget line. What the pension and health engine adds on top of that contract is a single guarantee: the contribution applied to any validated line is always resolvable to a version-stamped trust schedule and a reproducible payload hash, even under a volatile multi-jurisdiction shooting schedule.

The primary inputs the engine consumes are a payroll transaction — employee, classification, jurisdiction, gross compensation, and the timezone-aware date the work was performed — and a trust schedule keyed by guild classification and agreement version. The output is a resolved contribution pair (pension and health) plus a provenance record: the pensionable base actually used, the agreement version that produced the rates, and the SHA-256 hash of the canonical payload.

Architecture: Normalize, Determine the Pensionable Base, Then Apply the Schedule

The engine is a strict pipeline, not an ad-hoc formula. A validated transaction is first canonicalized and hashed so its provenance is fixed before any arithmetic runs. The classification then resolves a version-stamped trust schedule; the schedule’s pensionable ceiling caps the contributory base, because most guild agreements stop accruing pension above a per-engagement earnings cap. Only then are the pension and health rates applied to that capped base, each result quantized to whole cents under a deterministic rounding context. Every successful computation writes an immutable audit entry; every failure that reflects a bad record — an unknown classification, a negative gross, an unmapped jurisdiction — diverts to a quarantine queue rather than silently accruing zero. The distinction matters to a completion guarantor: a resolved contribution and a quarantined exception are different states, and conflating them is how a payroll ledger quietly goes wrong.

The diagram below shows the contribution path from gross wages through schedule lookup to an immutable audit entry, including the fallback queue branch that fires when validation fails.

Pension and health contribution flow with a fallback quarantine branch A validated payroll transaction enters as gross wages, then the engine determines the contributory base as the lesser of gross and the agreement ceiling. A decision node asks whether a version-stamped trust schedule resolves for the classification. On the resolved path, the schedule rate is applied, the pension and health contributions are computed and quantized to whole cents, and an immutable audit-log entry is written. On the unresolved path, the transaction is quarantined, appended to a fallback queue, and surfaced for manual override and an exception report, which also lands in the same audit log. A resolved contribution and a quarantined exception are distinct states that are never conflated. Yes — schedule resolves No / invalid exception rejoins the ledger Gross wages validated transaction Determine contributory base min(gross, pensionable ceiling) Trust schedule resolves? Apply trust-schedule rate version-stamped agreement Compute pension & health Decimal · quantized to cents Immutable audit-log entry hash · agreement version · base Quarantine transaction reason code · SHA-256 payload Append to fallback queue never zero-accrued Manual override exception report · triage resolved contribution quarantined exception

Where the resolution of a missing schedule is concerned — a rate table that simply is not present when the close runs — the tiered routing that keeps payroll moving is specified separately in Compliance Fallback Chains. This engine assumes a schedule either resolves cleanly or is a validity failure to quarantine; it delegates availability failures to that chain.

Core Implementation

The implementation proceeds in three steps: define the validated boundary models, canonicalize and hash each transaction, then compute the capped contribution against a version-stamped schedule.

Step 1 — Model the trust schedule and the transaction as validated Pydantic v2 types. Rates are constrained to the [0, 1] interval at the boundary, gross compensation may not be negative, and the work date must be timezone-aware — an offset-naive datetime is rejected outright, because a P&H rate that changes on a ratification date cannot be resolved from an ambiguous local timestamp.

Step 2 — Fix provenance before arithmetic. The transaction is serialized to a canonical, key-sorted JSON form and hashed with SHA-256. That hash is computed from the same bytes every time, so re-running a disputed week yields an identical fingerprint — the idempotency property that lets an accountant replay the close deterministically.

Step 3 — Cap the base, apply the schedule, quantize to cents. The pensionable base is the lesser of gross compensation and the agreement’s pensionable ceiling; each contribution is base × rate quantized to 0.01 under ROUND_HALF_UP.

from __future__ import annotations

import hashlib
import json
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP, getcontext
from enum import Enum
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

# Deterministic context for all fringe arithmetic — never float for money.
getcontext().prec = 34
CENTS = Decimal("0.01")
PRODUCTION_TZ = ZoneInfo("America/Los_Angeles")


class Classification(str, Enum):
    SAG_PERFORMER = "SAG_PERFORMER"
    DGA_DIRECTOR = "DGA_DIRECTOR"
    WGA_WRITER = "WGA_WRITER"


class TrustSchedule(BaseModel):
    """One guild trust-fund contribution schedule, version-stamped for audit."""
    model_config = ConfigDict(frozen=True)

    classification: Classification
    pension_rate: Decimal
    health_rate: Decimal
    pensionable_ceiling: Decimal      # per-engagement cap on the contributory base
    agreement_version: str            # e.g. "SAG-AFTRA-2023-Codified-Basic"

    @field_validator("pension_rate", "health_rate")
    @classmethod
    def _rate_is_fraction(cls, v: Decimal) -> Decimal:
        if not (Decimal("0") <= v <= Decimal("1")):
            raise ValueError("contribution rate must be a fraction between 0 and 1")
        return v


class PayrollTransaction(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    employee_id: str = Field(min_length=1)
    classification: Classification
    jurisdiction_code: str = Field(min_length=2, max_length=2)
    gross_compensation: Decimal
    worked_at: datetime

    @field_validator("gross_compensation")
    @classmethod
    def _non_negative(cls, v: Decimal) -> Decimal:
        if v < 0:
            raise ValueError("gross compensation cannot be negative")
        return v

    @field_validator("worked_at")
    @classmethod
    def _tz_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("worked_at must be timezone-aware (use an IANA zone)")
        return v

    def audit_hash(self) -> str:
        canonical = json.dumps(
            {
                "employee_id": self.employee_id,
                "classification": self.classification.value,
                "jurisdiction_code": self.jurisdiction_code,
                "gross_compensation": str(self.gross_compensation),
                "worked_at": self.worked_at.astimezone(PRODUCTION_TZ).isoformat(),
            },
            sort_keys=True,
            separators=(",", ":"),
        )
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


class ContributionResult(BaseModel):
    audit_hash: str
    agreement_version: str
    pensionable_base: Decimal
    pension: Decimal
    health: Decimal
    status: str
    computed_at: datetime


class PensionHealthCalculator:
    def __init__(self, schedules: dict[Classification, TrustSchedule]):
        self._schedules = schedules
        self.quarantine: list[dict] = []

    def compute(self, txn: PayrollTransaction) -> ContributionResult:
        schedule = self._schedules.get(txn.classification)
        if schedule is None:
            return self._quarantine(txn, "no_trust_schedule_for_classification")

        # Pension stops accruing above the agreement's per-engagement ceiling.
        pensionable_base = min(txn.gross_compensation, schedule.pensionable_ceiling)
        pension = (pensionable_base * schedule.pension_rate).quantize(CENTS, ROUND_HALF_UP)
        health = (pensionable_base * schedule.health_rate).quantize(CENTS, ROUND_HALF_UP)

        return ContributionResult(
            audit_hash=txn.audit_hash(),
            agreement_version=schedule.agreement_version,
            pensionable_base=pensionable_base,
            pension=pension,
            health=health,
            status="calculated",
            computed_at=datetime.now(PRODUCTION_TZ),
        )

    def _quarantine(self, txn: PayrollTransaction, reason: str) -> ContributionResult:
        self.quarantine.append({
            "audit_hash": txn.audit_hash(),
            "reason": reason,
            "payload": txn.model_dump(mode="json"),
            "quarantined_at": datetime.now(PRODUCTION_TZ).isoformat(),
            "requires_manual_override": True,
        })
        return ContributionResult(
            audit_hash=txn.audit_hash(),
            agreement_version="UNRESOLVED",
            pensionable_base=Decimal("0.00"),
            pension=Decimal("0.00"),
            health=Decimal("0.00"),
            status="quarantined",
            computed_at=datetime.now(PRODUCTION_TZ),
        )


def ingest(raw: dict) -> PayrollTransaction | None:
    """Boundary parse: malformed records are logged, never silently coerced."""
    try:
        return PayrollTransaction.model_validate(raw)
    except ValidationError as exc:
        # In production, push exc.errors() to the reconciliation queue.
        print("rejected at boundary:", exc.error_count(), "issue(s)")
        return None

Two properties of this code are load-bearing for audit defensibility. First, gross_compensation and every rate are Decimal, and min(...) on Decimal values preserves that type — no float ever touches a monetary quantity. Second, worked_at is normalized to a single production timezone via zoneinfo before it enters the hash, so the same shoot day always canonicalizes identically regardless of the offset the source system emitted.

Guild and Contract Specifics

The three classifications above stand in for the trust funds a scripted production touches most often, and each carries its own contribution schedule negotiated in a distinct collective bargaining agreement (CBA). For performers, the Screen Actors Guild-American Federation of Television and Radio Artists (SAG-AFTRA) sets pension and health rates as a percentage of covered earnings up to a contract ceiling; the details of segregating pensionable from non-pensionable compensation before remittance are worked through in Automating SAG-AFTRA Pension Contributions Tracking. For directors and unit production managers, the Directors Guild of America (DGA) ties pensionable earnings to hours worked relative to standard turnaround windows, so overtime premiums can push gross pay across a contribution tier boundary — which is exactly why the base must be computed after the penalties defined in DGA Overtime & Turnaround Rules are applied, not before. For writing staff, the Writers Guild of America (WGA) validates tiered compensation against current guild minimums, and secondary payments interact with reuse: whether pension applies to a residual at all is resolved by the reuse-window logic in SAG-AFTRA Residuals Logic before this engine ever sees the base. Crew health-and-welfare caps under the International Alliance of Theatrical Stage Employees (IATSE) follow the same modeling: a per-hour or per-engagement ceiling that bounds the contributory base.

The rate values in the code are illustrative placeholders. In production, the schedule is loaded from a version-controlled, signed source — never hardcoded — because contribution rates and ceilings change with every bargaining cycle, and a stale rate applied to a live remittance is itself an audit finding. The agreement_version field exists precisely so that the schedule a contribution was computed against is recoverable months later, when a trust fund questions a specific line.

Comparison matrix of three guild trust schedules and the capped base the rate multiplies A table with three rows — SAG-AFTRA performer, DGA director, and WGA writer — and columns for pension rate, health rate, and pensionable ceiling. The illustrative rates and ceilings differ per guild, so the same gross compensation yields a different contribution in each. A highlighted band below the table states that the rate never multiplies raw gross: it multiplies the pensionable base, which is the lesser of gross compensation and the agreement ceiling. A worked example shows a two hundred eighty thousand dollar gross capped to a two hundred fifty thousand dollar ceiling before the pension rate is applied. All rates and ceilings shown are illustrative placeholders, not current negotiated figures. Guild trust fund Pension rate Health rate Pensionable ceiling SAG-AFTRA performer 5.50% 11.50% $250,000 DGA director 6.00% 10.50% $260,000 WGA writer 6.00% 11.00% $250,000 The rate multiplies the capped base — never raw gross pensionable base = min(gross compensation, agreement ceiling) gross = $280,000 ceiling = $250,000 base = $250,000 SAG-AFTRA pension = $250,000 × 5.50% = $13,750.00 $30,000 of gross is above the cap Rates and ceilings are illustrative placeholders, not current negotiated figures.

Error Handling and Quarantine

Not every failure is the same kind of failure, and the engine must not blur them. A malformed inbound record — a non-numeric gross, a two-letter jurisdiction code that is actually four characters, an offset-naive worked_at — is rejected at the boundary by model_validate before it can become a transaction at all; ingest surfaces the structured ValidationError for reconciliation rather than coercing a guess. A structurally valid transaction whose classification has no schedule is a different case: it is a real record that cannot yet be priced, so it is quarantined, not dropped and not zero-accrued.

Every quarantine event serializes the original payload verbatim via model_dump(mode="json"), attaches the SHA-256 hash of the canonical transaction, records a machine-readable reason code, and flags the record for manual override. The failing record never enters the ledger as a resolved contribution — it enters a separate exception store, so the payroll ledger stays clean while the discrepancy is preserved for triage. This mirrors the boundary discipline of Schema Validation & Error Handling: a rejected record always carries its reason code, its original bytes, and its hash, so a production accountant can reconcile a single line without re-ingesting the whole weekly batch. The engine never silently defaults to zero, because a silent zero is indistinguishable, at audit time, from a legitimately zero contribution — and that ambiguity is what a completion guarantor will not accept.

Verification

A contribution engine is only trustworthy if you can prove, after the fact, how every number was produced. Verification checks three artifacts.

First, the ledger entry. Every calculated result must carry a pensionable_base that equals min(gross, ceiling), a pension and health that are exact Decimal values quantized to two places, and an agreement_version that names the schedule applied. Re-running compute against the same transaction must yield an identical audit_hash — the idempotency check that lets a disputed week be replayed deterministically.

Second, the audit log fields. Each computation should log, at minimum: employee_id, classification, jurisdiction_code, agreement_version, pensionable_base, pension, health, audit_hash, and a timezone-aware computed_at. A trust-fund examiner reads this row as the provenance of the remittance, so no field is optional.

Third, the reconciliation report shape. A weekly report should surface, per fund code: total pensionable base, total pension and health accrued, the count of records that hit the pensionable ceiling (a signal of high-earner concentration), and every quarantined record awaiting triage. A healthy run is almost entirely calculated with a short, explained quarantine tail; a run with a growing quarantine list is a signal that a classification mapping or a schedule needs attention before the remittance deadline rather than after a trust fund flags it.

A minimal harness confirms the invariants:

def verify(result: ContributionResult, txn: PayrollTransaction,
           schedule: TrustSchedule) -> None:
    expected_base = min(txn.gross_compensation, schedule.pensionable_ceiling)
    assert result.pensionable_base == expected_base, "base must respect the ceiling"
    assert result.pension == (expected_base * schedule.pension_rate).quantize(CENTS, ROUND_HALF_UP)
    assert result.audit_hash == txn.audit_hash(), "resolution must be idempotent"
    assert result.computed_at.tzinfo is not None, "timestamps must be timezone-aware"

Enforced this way — Decimal fringe math, a version-stamped schedule, a capped pensionable base, and a reproducible payload hash on every line — a P&H calculation engine turns a volatile, audit-prone line item into a bond-ready, defensible pipeline that scales across multi-jurisdiction shoots, mixed union rosters, and compressed close schedules.

Up: Guild Compliance & Rule Validation Automation