Skip to content

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:

  1. Increment access: af.av.increments["COI"]
  2. Composition: base.insert_before("Interest", Step.charge(...))
  3. 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.