Rollforward Methods¶
The Problem¶
Universal Life, Variable Annuity, and Indexed UL products all share the same challenge: charges at time t depend on the accumulated value at t, which depends on those charges. Cost of Insurance is charged on max(0, death_benefit - AV) — but AV itself changes as COI is deducted. Tiered fees depend on which AV band the policy falls in. IUL crediting clamps to a floor and cap applied to the current balance.
Gaspatchio's accumulate() handles State[t] = State[t-1] * M + A — the case where M and A are pre-computable. The rollforward API handles everything else: any product where within-period charges depend on the running account value.
A Complete Example¶
from gaspatchio_core import ActuarialFrame
data = {
"av_init": [1000.0, 5000.0],
"premium": [[100.0, 100.0, 100.0], [500.0, 500.0, 500.0]],
"coi_rate": [[0.001, 0.001, 0.001], [0.002, 0.002, 0.002]],
"sum_assured": [[50000.0, 50000.0, 50000.0], [100000.0, 100000.0, 100000.0]],
"admin_rate": [[0.01, 0.01, 0.01], [0.01, 0.01, 0.01]],
"interest_rate": [[0.004, 0.004, 0.004], [0.003, 0.003, 0.003]],
}
af = ActuarialFrame(data)
# UL rollforward with per-step increment tracking
af.av = (
af.projection.rollforward(initial=af.av_init, track_increments=True)
.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)
)
# Per-step dollar amounts — single run, no re-computation
af.coi_amount = af.av.increments["COI"]
af.interest_credited = af.av.increments["Interest"]
result = af.collect()
The chain reads top-to-bottom as the within-period calculation order. Each step operates on the current account value after all preceding steps. The second argument to each step is a label — used for increment access, composition, and the formula table.
What You Can Do¶
| Capability | What It Solves | Learn More |
|---|---|---|
| 14 step methods | Premium deposits, rate charges, COI on NAR, IUL floor/cap, conditional fees | Choosing Steps |
| Increment tracking | Per-step dollar decomposition for IFRS 17 analysis of change — in a single run | Increment Tracking |
| Step composition | Build 20 product variants from one base without duplicating code | Step Composition |
| Multi-state | VA + GMDB ratchet, GMWB proportional reduction, secondary guarantee lapse | Multi-State |
| Inspection | Formula table, SHA-256 fingerprint for model governance | Inspection |
Three Concepts to Know First¶
Labels¶
Every step has a label — either the one you provide ("COI") or an auto-generated one ("Charge(admin_rate)"). Labels are the single addressing mechanism for three things:
- Increment access:
af.av.increments["COI"] - Composition:
base.insert_before("Interest", Step.charge(...)) - Formula table: the "Label" column in
.explain()output
Labels must be unique within a builder. Use explicit labels when you plan to access increments or compose.
Lazy Evaluation¶
The builder chain does no computation. It compiles when you assign it to a column (af.av = builder). The Rust kernel runs once when you call .collect(). This is the same model as any Polars expression — build the plan, execute it later.
Immutability¶
Every method returns a new builder. The original is never modified:
base = (
af.projection.rollforward(initial=af.av_init)
.add(af.premium, "Premium")
.charge(af.admin_rate, "Admin")
.grow(af.interest_rate, "Interest")
.floor(0)
)
# base is unchanged — each returns a new builder
from gaspatchio_core.rollforward import Step
with_rider = base.insert_before("Interest", Step.charge(af.rider_rate, "Rider"))
without_admin = base.remove("Admin")
This means you can create as many product variants as you need from a single base definition.
Step Methods at a Glance¶
| Family | Methods | When to Use |
|---|---|---|
| Absolute | .add(amount), .subtract(amount) |
Premium deposits, flat-dollar fees, withdrawals |
| Rate | .charge(rate), .grow(rate), .grow_capped(rate, floor, cap) |
M&E charges, interest, IUL crediting with floor/cap |
| Actuarial | .deduct_nar(rate, death_benefit=db) |
COI on net amount at risk |
| Bounds | .floor(value), .cap(value) |
Non-negative AV constraint, maximum limit |
| Conditional | .add_if(cond, amount), .charge_if(cond, rate) |
Rider charges applied only to certain policies |
| Control | .lapse_if_zero(), .capture(name) |
Single-state lapse, mid-chain snapshots |
| Multi-state | .ratchet_to(state), .pro_rata_with(ref, amount) |
GMDB high-water mark, GMWB proportional reduction |
See Choosing Steps for the decision tree, formulas, and examples.
Multi-State — When One Account Isn't Enough¶
For products with interacting accounts, pass named keyword arguments:
rf = (
af.projection.rollforward(av=af.av_init, guarantee=af.guarantee_init)
.on("av").add(af.premium, "Premium")
.on("av").grow(af.fund_return, "Fund Return")
.on("guarantee").ratchet_to("av", "GMDB Ratchet")
)
af.av = rf["av"]
af.guarantee = rf["guarantee"]
.on(state_name) sets which account subsequent steps target. It's sticky — stays on the named state until you switch. The first declared state is the default before any .on() call.
See Multi-State for VA + GMDB, GMWB, and secondary guarantee patterns.
Inspection and Governance¶
print(base.explain())
Rollforward: initial=av_init, 4 steps
Step Operation Label Formula
---- --------------------- --------- ---------------------------------
1 Add(premium) Premium av[t] = av[t] + premium[t]
2 Charge(admin_rate) Admin av[t] = av[t] * (1 - admin_rate[t])
3 Grow(interest_rate) Interest av[t] = av[t] * (1 + interest_rate[t])
4 Floor(0) Floor(0) av[t] = max(av[t], 0)
base.fingerprint() # "sha256:a1b2c3d4..." — changes when structure changes
See Inspection for explain(), fingerprint(), canonical(), and common mistakes.
What This API Does Not Do¶
- No Python callbacks in the inner loop. All step logic is declarative and serialized to the Rust kernel. You cannot call arbitrary Python functions mid-projection. This constraint is what enables composition, fingerprinting, and inspection.
- No automatic step reordering. Steps execute in declaration order. Use
.explain()to verify the order matches your product spec. - No conditional dispatch to different step sequences. If different products need different steps, filter into separate frames or use
.charge_if()/.add_if()for per-step conditions.
When to Use rollforward() vs accumulate()¶
| Situation | Use |
|---|---|
| Charges are independent of AV (pre-computable) | accumulate() |
| Any charge depends on the running AV (COI, tiered fees) | rollforward() |
| Multiple interacting accounts (VA + GMDB) | rollforward() with multi-state |
| Need per-step increment decomposition | rollforward() with track_increments=True |
| Term life reserves (no AV) | accumulate() or prospective_value() |
When in doubt, use rollforward(). It handles everything accumulate() does, plus state-dependent operations.