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_ratetomonthly_coidoesn't change the value - Changing a
labelfrom"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.