Generating Guarantor-Facing Variance Reports in Python

A completion guarantor reconciling a period submission works one row at a time: it opens a cost code, reads the budget the row measured against, checks the actual and the forecast to finish, and asks why the delta is what it is and where the number came from. The exact task this page solves is producing that row — and the whole ordered set of them — as a single deterministic function that takes a bonded budget and a cost report and returns budget, actual, variance, variance percentage, and an explanation code for every code, each traceable to a source row and reproducible to the cent months later. Two mistakes fracture the output under real conditions: computing money in binary floating point, which lets a fractional cent drift until the report total no longer ties to its rows, and dividing by a zero budget, which throws mid-run on the very unbudgeted spend the guarantor most wants to see. This walkthrough builds the assembly as a pure function over Decimal, with the percentage and zero-budget cases handled explicitly.

Prerequisites and Context

This page extends the engine specified in Guarantor Variance Reporting; it assumes budget and actual lines have already been cost-coded and typed upstream, and focuses entirely on turning them into a finished report package. It targets Python 3.11+ for standard-library zoneinfo, and uses the same deliberate dependency set as the rest of Completion Bond Reporting & Guarantor Analytics: decimal for every monetary value and every percentage, zoneinfo for a timezone-aware report stamp, hashlib for the report fingerprint, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Rows reach this function already parsed by the upstream Cost Ingestion & Data Parsing Workflows subsystem, and each line keys to exactly one budget line through Cost Code Standardization so a delta maps cleanly onto the ledger. The estimate at completion carried on each actual line originates in Cost-to-Complete Projections; because tolerance bands and the reporting baseline vary by bond and by approved revision, treat the tolerance and the budget-revision id as configurable run parameters rather than constants baked into the code.

Step-by-Step Implementation

Assembly begins at the boundary. A Pydantic v2 model rejects any monetary field supplied as a float, because a binary-float amount is the single largest source of a report total that silently fails to tie to its rows. Matching is done on cost code from both sides so an unmatched line surfaces rather than vanishes; each delta is a Decimal subtraction; the percentage is guarded against a zero base; and the finished body is hashed so the package recomputes identically. The function below produces the ordered rows, the totals, and the fingerprint in one pass.

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, Field, field_validator

CENTS = Decimal("0.01")
# Tolerance band and baseline id are bond parameters, not hardcoded law.
TOLERANCE = Decimal("0.02")


class Explanation(str, Enum):
    ON_BUDGET = "on_budget"
    TIMING = "timing"
    OVERRUN = "overrun"
    UNDERRUN = "underrun"
    NO_BUDGET = "no_budget"


class BudgetLine(BaseModel):
    model_config = ConfigDict(frozen=True)
    cost_code: str
    description: str
    budget: Decimal = Field(ge=Decimal("0"))

    @field_validator("budget", mode="before")
    @classmethod
    def _no_float(cls, v):
        if isinstance(v, float):
            raise ValueError("budget must be str/Decimal, never float")
        return Decimal(str(v))


class ActualLine(BaseModel):
    model_config = ConfigDict(frozen=True)
    cost_code: str
    source_ref: str                       # cost-report row the delta traces to
    actual: Decimal = Field(ge=Decimal("0"))
    estimate_at_completion: Decimal = Field(ge=Decimal("0"))

    @field_validator("actual", "estimate_at_completion", mode="before")
    @classmethod
    def _no_float(cls, v):
        if isinstance(v, float):
            raise ValueError("monetary fields must be str/Decimal, never float")
        return Decimal(str(v))


@dataclass(frozen=True)
class ReportRow:
    cost_code: str
    description: str
    source_ref: str
    budget: Decimal
    actual: Decimal
    eac: Decimal
    variance_to_eac: Decimal
    variance_pct: Decimal
    explanation: Explanation


def _q(amount: Decimal) -> Decimal:
    return amount.quantize(CENTS, rounding=ROUND_HALF_UP)


def _variance_pct(budget: Decimal, variance: Decimal) -> Decimal:
    # Zero-budget divide guard: percentage is undefined against a nil base.
    if budget == 0:
        return Decimal("0.0")
    return (variance / budget * Decimal("100")).quantize(
        Decimal("0.1"), rounding=ROUND_HALF_UP
    )


def _classify(budget: Decimal, actual: Decimal, eac: Decimal) -> Explanation:
    if budget == 0:
        return Explanation.NO_BUDGET
    band = budget * TOLERANCE
    if eac > budget + band:
        return Explanation.OVERRUN
    if eac < budget - band:
        return Explanation.UNDERRUN
    if actual < budget - band:
        return Explanation.TIMING
    return Explanation.ON_BUDGET


def generate_report(budget_lines: list[BudgetLine],
                    actual_lines: list[ActualLine],
                    baseline_id: str) -> dict:
    budgets = {b.cost_code: b for b in budget_lines}
    actuals = {a.cost_code: a for a in actual_lines}

    quarantine = [
        {"cost_code": code,
         "reason_code": "no matching budget line" if code in actuals
         else "no matching actual line"}
        for code in budgets.keys() ^ actuals.keys()
    ]

    rows: list[ReportRow] = []
    for code in sorted(budgets.keys() & actuals.keys()):
        b, a = budgets[code], actuals[code]
        var_eac = _q(b.budget - a.estimate_at_completion)
        rows.append(ReportRow(
            cost_code=code,
            description=b.description,
            source_ref=a.source_ref,
            budget=b.budget,
            actual=a.actual,
            eac=a.estimate_at_completion,
            variance_to_eac=var_eac,
            variance_pct=_variance_pct(b.budget, var_eac),
            explanation=_classify(b.budget, a.actual, a.estimate_at_completion),
        ))

    total_budget = _q(sum((r.budget for r in rows), Decimal("0")))
    total_eac = _q(sum((r.eac for r in rows), Decimal("0")))
    body = [
        {**row.__dict__,
         "budget": str(row.budget), "actual": str(row.actual),
         "eac": str(row.eac), "variance_to_eac": str(row.variance_to_eac),
         "variance_pct": str(row.variance_pct),
         "explanation": row.explanation.value}
        for row in rows
    ]
    canonical = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
    return {
        "baseline_id": baseline_id,
        "rows": body,
        "total_budget": str(total_budget),
        "total_eac": str(total_eac),
        "total_variance_to_eac": str(_q(total_budget - total_eac)),
        "quarantine": quarantine,
        "computed_at": datetime.now(tz=ZoneInfo("UTC")).isoformat(),
        "payload_sha256": hashlib.sha256(canonical).hexdigest(),
    }

Because generate_report reads its tolerance and baseline from parameters, sorts by cost code, and never touches the network, identical inputs always produce an identical body and an identical hash. That determinism is what lets a guarantor’s analyst recompute a disputed period from the archived inputs alone and confirm the package was never altered after submission.

The assembly normalizes and matches both sides, computes each delta and its guarded percentage, classifies it against the tolerance band, and folds the ordered rows into a totalled, hashed package.

Variance report assembly flow A budget line set and an actual cost-report line set are matched by cost code. A matched pair computes a Decimal budget-to-EAC delta, then a decision asks whether the line budget is zero. A zero budget yields a zero-percent variance flagged no_budget; a non-zero budget yields a guarded variance percentage. Both converge on a classification step that assigns an explanation code against the tolerance band, then the ordered rows are totalled and the body is serialized and stamped with a SHA-256 hash. A separate dashed branch shows any line matching only one side diverted to quarantine. Match budget + actual keyed on cost code one side only Quarantine line reason_code delta = budget − EAC Decimal budget = 0? zero-base guard yes variance_pct 0.0 flag no_budget no Guarded percentage variance / budget Classify vs tolerance explanation code Total ordered rows budget · EAC · variance Serialize + SHA-256 immutable package

Audit Trail Requirements

Every report — including a period where nothing breaches its tolerance — must be serialized to a write-once store so the guarantor sees an unbroken sequence of submissions rather than only the periods with exceptions. At minimum the persisted package must carry: the baseline_id naming the budget revision it measured against; each row’s cost_code, description, and source_ref; the budget, actual, and eac as strings; the variance_to_eac, variance_pct, and explanation; the report totals; the quarantine list; a UTC computed_at; and the payload_sha256 over the canonical body. Persist the package to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any downstream ledger transaction commits, so a crash mid-run leaves a replayable record of intent rather than a gap. A later correction is a new report with a new hash referencing the superseded one, never an edit of the submitted package, so the figures a guarantor already reviewed stay recoverable. This is the same deterministic audit discipline the parent Guarantor Variance Reporting engine enforces, which is why the hashing and canonical serialization belong in one shared module rather than duplicated per report type.

Gotchas and Production Edge Cases

Rounding the percentage. Compute the delta and the percentage as separate Decimal quantizations — the cents figure to two places, the percentage to one — and never derive the percentage from an already-rounded amount, or the rounding error double-counts. A guarantor tie-out sums the row amounts, not the percentages, so the amounts must be exact to the cent while the percentage stays a display figure; quantizing both at their own precision keeps the total honest and the presentation clean.

The zero-budget divide. An unbudgeted cost code is the first symptom of scope creep, and it is exactly where a naive variance / budget throws ZeroDivisionError mid-run. Guard the divide explicitly, report the percentage as 0.0, and flag the row no_budget so the spend is surfaced as an exception the guarantor can see rather than an error that aborts the whole package. Suppressing the row entirely is worse than crashing: it hides the one number the analyst is looking for.

Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error at the source. Read every amount through its string form, as the Decimal(str(...)) validators above do, so a single fractional cent compounded across a few thousand cost lines never becomes the variance a guarantor asks you to explain. Rejecting float at the boundary, rather than coercing it, means a mis-typed upstream export fails loudly instead of reconciling cleanly to a wrong total.

Idempotency on replay. Because the body hash is deterministic and the store is append-only, re-running a period after a partial failure is safe only if the consumer deduplicates on (baseline_id, payload_sha256); without that guard a retried run double-files the package. Sorting rows by cost code before hashing is what makes the fingerprint independent of the order rows happened to arrive in — two runs over the same data in a different sequence must produce the same hash, or the guarantor’s recomputation will not match.

Provisional estimates. When an estimate at completion is still settling, carry a provisional flag on the row and total the provisional exposure separately, rather than presenting a forecast as a settled overrun. A category trending toward a forced reduction in remaining spend is watched against the drawdown schedule in Contingency Drawdown Tracking; before a report is even trusted as a baseline, its cost figures should be tied out against the guarantor’s own statement through Reconciling the Cost Report to the Bond Cost Statement.

Up one level: Guarantor Variance Reporting, part of Completion Bond Reporting & Guarantor Analytics.