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.

The compiled rollforward exposes three inspection helpers — explain(), fingerprint(), and canonical_form() — that cover the typical governance, audit, and quarter-over-quarter change-detection flows.

compiled.explain() — Human-Readable Summary

Renders the model as plain text suitable for audit reports, peer review, and TRACE logs. Building on the Overview example:

from datetime import date

import polars as pl

from gaspatchio_core import (
    ActuarialFrame,
    Schedule,
    compile_rollforward,
)

af = ActuarialFrame(
    pl.DataFrame(
        {
            "av_init": [1_000.0],
            "premium": [[100.0] * 12],
            "coi_rate": [[0.001] * 12],
            "sum_assured": [[50_000.0] * 12],
            "admin_rate": [[0.01] * 12],
            "interest_rate": [[0.004] * 12],
        }
    )
)
sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=12, frequency="1M"
)
af = af.projection.set(schedule=sched)

b = af.projection.rollforward(
    states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(
    af["coi_rate"], death_benefit=af["sum_assured"], label="COI"
)
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow(af["interest_rate"], label="Interest")
b["av"].floor(value=0.0)

compiled = compile_rollforward(b)
print(compiled.explain())
Rollforward (spec_fingerprint = sha256:5b5d5ab69eaa8bcd30c3269128c4899f37f565661d921380cd31594263b43f1f)

States:
  av:  init=col("av_init")

Points:  bop, eop

Schedule: from_calendar_grid({'kind': 'from_calendar_grid', 'n_periods': 12, 'frequency': '1M', 'calendar': 'NullCalendar', 'convention': 'Unadjusted', 'day_count': 'OneTwelfth', 'anchor': 'month_end', 'start_date': '2025-01-31'})

Transitions (in order):
  av@eop  Add  [label='Premium']
  av@eop  DeductNAR  [label='COI']
  av@eop  Charge  [label='Admin']
  av@eop  Grow  [label='Interest']
  av@eop  Floor

batch_axes: ('policy',)
track_increments: False
lapse_when_all_non_positive: []
contract_boundary: None
engine_binding: portable

Use this to:

  • Verify step order against the product specification before running the projection
  • Document the model logic for peer review
  • Capture model state in audit reports

The engine_binding line confirms how the projection will run. portable means every step uses only the supported set of operations and can run anywhere gaspatchio runs.

compiled.fingerprint() — Change Detection

Returns a single value that pins down the model's structure — the steps, in their order, against their accounts, on their Schedule:

compiled.fingerprint()
# 'sha256:a1b2c3d4e5f6...'

Two compiled rollforwards with the same step sequence in the same order, the same accounts, the same Schedule, and the same configuration produce the same value — even if the input column names differ. This means:

  • Renaming coi_rate to monthly_coi doesn't change the value
  • Changing a label from "COI" to "Cost of Insurance" does change it (labels are addressable identifiers, not aliases)
  • Adding, removing, or reordering a step does change it

Use this for:

  • Model governance: Record fingerprint() alongside each quarterly run. If it changes without a deliberate release, the model structure has drifted between quarters and your AOM is going to need to explain it.
  • Release evidence: Compare values across releases to catch structural changes that slipped through review.
  • Audit trail: Log the value in run metadata so you can show, for any historical result, which model actually produced it.

compiled.canonical_form() — Machine-Readable Structure

Returns a structured description of the model — the same description that backs fingerprint(). Use it when you need to see what changed, not just that something did:

import json
print(json.dumps(compiled.canonical_form(), indent=2))
{
  "states": [
    {"name": "av", "init": "col(\"av_init\")"}
  ],
  "points": ["bop", "eop"],
  "transitions": [
    {"op": "Add", "target": "av@eop", "expr": "col(\"premium\")", "label": "Premium"},
    {"op": "DeductNAR", "target": "av@eop", "coi_rate": "col(\"coi_rate\")",
     "death_benefit": "col(\"sum_assured\")", "label": "COI"},
    {"op": "Charge", "target": "av@eop", "rate": "col(\"admin_rate\")", "label": "Admin"},
    {"op": "Grow", "target": "av@eop", "rate": "col(\"interest_rate\")", "label": "Interest"},
    {"op": "Floor", "target": "av@eop", "value": 0.0}
  ],
  "schedule": {
    "kind": "from_calendar_grid", "n_periods": 12, "frequency": "1M",
    "calendar": "NullCalendar", "convention": "Unadjusted",
    "day_count": "OneTwelfth", "anchor": "month_end",
    "start_date": "2025-01-31"
  },
  "track_increments": false,
  "lapse_when_all_non_positive": [],
  "contract_boundary": null,
  "engine_binding": "portable"
}

Diff two canonical forms to localise structural drift between releases.

Direct Inspection of the Step Sequence

For programmatic introspection, walk the compiled chain directly:

compiled.ir.transitions   # the steps, in order, as typed dataclasses
compiled.ir.states        # the declared accounts
compiled.ir.points        # ("bop", "eop") or any custom points
compiled.ir.schedule      # the bound Schedule

Each entry in transitions is a step record — Add(target=..., expr=..., label=...), Grow(...), Charge(...), and so on. Use this when you need structured access for tooling — a CI check that asserts every COI step has a positive rate, a reporter that groups steps by family, or a structural diff between two model variants.

Common Mistakes

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

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

.subtract(expr) subtracts a dollar amount (e.g., 15.00).

If the product spec says "$15 per month admin fee", use .subtract. If it says "0.15% of AV", use .charge.

Forgetting .floor(value=0.0) on UL products

Without a floor, AV can go negative (and stay negative). Most UL contracts guarantee a non-negative account value at end of period. End the chain with .floor(value=0.0) unless the product spec explicitly allows negatives.

Wrong step order

Steps execute in declaration order. Crediting interest before deducting COI means the policyholder earns interest on money that should have been charged. Match the order to the product's within-period calculation convention. Use compiled.explain() to verify.

Annual rates without periodisation

If the projection is monthly but the rates are annual, periodise first:

af.monthly_rate = af["annual_rate"] / 12             # simple
af.monthly_rate_compound = (1 + af["annual_rate"]) ** (1 / 12) - 1  # compound

The model uses what you pass it; it does not infer the calendar of the input rates. Convert annual rates to monthly (or whatever your projection frequency is) before assigning to the input frame.

Cross-period account references

An account's value at period t is determined by the chain executed at t. There is no built-in way to read an account's value at t−1 from inside the chain — sequencing periods is what the rollforward does for you. If you need a derivative quantity that depends on a prior-period value (a rolling-window cap on growth, for example), produce it as an input column with a shift(1) over an already-computed account output, then use it in a downstream rollforward.

Schedule mismatch

The Schedule's n_periods is what determines projection length. List-column inputs (premium, coi_rate, etc.) must be the same length, or the projection will fail with a length mismatch. Build the Schedule once and reuse it everywhere — for the inputs, for the chain, and for any anniversary masks.