Skip to content

Inspection and Governance

Seeing What Your Model Does

Before running a rollforward against 100,000 policies, you want to verify the step order matches the product spec. After running it, you want to confirm nothing has changed since last quarter.

.explain() — Formula Table

Print the step-by-step formula for any rollforward:

base_ul = (
    af.projection.rollforward(initial=af.av_init)
    .add(af.premium, "Premium")
    .deduct_nar(af.coi_rate, death_benefit=af.sum_assured, label="COI")
    .charge(af.admin_rate, "Admin")
    .grow(af.interest_rate, "Interest")
    .floor(0)
)

print(base_ul.explain())
Rollforward: initial=av_init, 5 steps

  Step  Operation              Label      Formula
  ----  ---------------------  ---------  -----------------------------------------
  1     Add(premium)           Premium    av[t] = av[t] + premium[t]
  2     DeductNAR(coi_rate)    COI        av[t] = av[t] - coi_rate[t] * max(0, sum_assured[t] - av[t])
  3     Charge(admin_rate)     Admin      av[t] = av[t] * (1 - admin_rate[t])
  4     Grow(interest_rate)    Interest   av[t] = av[t] * (1 + interest_rate[t])
  5     Floor(0)               Floor(0)   av[t] = max(av[t], 0)

Use this to: - Verify step order against the product specification - Check that composed variants have the right steps in the right places - Document the model logic for peer review

.fingerprint() — Change Detection

Compute a SHA-256 hash of the model's structural form:

base_ul.fingerprint()
# "sha256:a1b2c3d4e5f6..."

Two rollforwards with the same step types in the same order produce the same fingerprint, regardless of column names or labels. This means:

  • Renaming coi_rate to monthly_coi doesn't change the fingerprint
  • Changing the label from "COI" to "Cost of Insurance" doesn't change the fingerprint
  • Adding, removing, or reordering a step does change the fingerprint

Use this for: - Model governance: Store the fingerprint alongside results. If the fingerprint changes between runs, the model structure changed. - Version control: Compare fingerprints across releases to detect structural drift. - Audit trail: Log the fingerprint in your run metadata to prove which model produced which results.

.canonical() — Structural Form

Get the machine-readable structural description:

base_ul.canonical()
{
    "num_states": 1,
    "steps": [
        {"operation": "add"},
        {"operation": "deduct_nar"},
        {"operation": "charge"},
        {"operation": "grow"},
        {"operation": "floor", "value": 0.0},
    ],
    "track_increments": False,
}

The canonical form includes only operation types and structural parameters (floor/cap values). It excludes column names and labels — these are environment-dependent aliases, not structural features of the model.


Common Mistakes

Using .charge() when you mean .subtract()

.charge(rate) multiplies: AV *= (1 - rate). It takes a rate (e.g., 0.01 for 1%).

.subtract(amount) subtracts a dollar amount (e.g., $15).

If your product spec says "$15 per month", use .subtract(). If it says "0.15% of AV", use .charge().

Forgetting .floor(0) on UL products

Without a floor, AV can go negative. Most UL contracts guarantee a non-negative account value. Always end with .floor(0) unless the product spec says otherwise.

Wrong step ordering

The steps execute in the order you declare them. If you credit interest before deducting COI, the policyholder earns interest on money that should have been charged. Match the order to the product's within-period calculation convention.

Check with .explain() if you're unsure.

Annual rates without monthly conversion

If your projection is monthly but your rates are annual, convert first:

af.monthly_rate = af.annual_rate / 12

Or for compound rates:

af.monthly_rate = (1 + af.annual_rate) ** (1 / 12) - 1