Skip to content

Building Product Variants

The Product Development Workflow

Real product development isn't linear. You build a base UL chain, then explore variations: "what if we add a rider charge?", "what if we strip the admin fee for the high-AV band?", "what if we replace flat crediting with an index strategy?".

The chain is mutable while you're building it. Each call to b["av"].add(...), .charge(...), etc. appends a step to the sequence. Once compile_rollforward runs, the sequence is fixed — variants are built by starting fresh builders and applying shared building blocks as ordinary Python.

The pattern: factor each block of related steps into a helper function that takes a builder and an account name. Compose the helpers in different orders for different product variants.

Setup

Every example below shares one frame and schedule. Build a small two-policy UL frame on a two-year monthly grid, carrying every column the variants reference:

from datetime import date

import polars as pl

from gaspatchio_core import (
    ActuarialFrame,
    RollforwardBuilder,
    RollforwardCollector,
    Schedule,
    compile_rollforward,
)

n_periods = 24
sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=n_periods, frequency="1M"
)
anniv_mask = sched.anniversary_mask()

af = ActuarialFrame(
    pl.DataFrame(
        {
            "policy_id": [1, 2],
            "av_init": [10_000.0, 25_000.0],
            "guarantee_init": [10_000.0, 25_000.0],
            "premium": [[1_200.0] * n_periods, [2_400.0] * n_periods],
            "coi_rate": [[0.0020] * n_periods] * 2,
            "sum_assured": [[250_000.0] * n_periods, [500_000.0] * n_periods],
            "admin_rate": [[0.0010] * n_periods] * 2,
            "interest_rate": [[0.0030] * n_periods] * 2,
            "fund_return": [[0.0050] * n_periods] * 2,
            "rider_rate": [[0.0005] * n_periods] * 2,
            "rider_rate_full": [[0.0005] * n_periods] * 2,
            "rider_active": [[True] * n_periods] * 2,
            "anniv_mask": [anniv_mask] * 2,
            "year_index": [list(range(1, n_periods + 1))] * 2,
        }
    )
)
af = af.projection.set(schedule=sched)

# Shared initial-value expression reused by several variant builders.
init = {"av": af["av_init"]}

A Reusable Helper Pattern

import polars as pl
from gaspatchio_core import ActuarialFrame, RollforwardBuilder, Schedule

def add_ul_charges(builder: RollforwardBuilder, state: str) -> None:
    """Standard UL within-period charges: COI, then admin."""
    h = builder[state]
    h.deduct_nar(
        pl.col("coi_rate"),
        death_benefit=pl.col("sum_assured"),
        label="COI",
    )
    h.charge(pl.col("admin_rate"), label="Admin")


def add_premium(builder: RollforwardBuilder, state: str) -> None:
    builder[state].add(pl.col("premium"), label="Premium")


def add_credit(builder: RollforwardBuilder, state: str) -> None:
    builder[state].grow(pl.col("interest_rate"), label="Interest")


def add_floor(builder: RollforwardBuilder, state: str) -> None:
    builder[state].floor(value=0.0)

Each helper appends a coherent block of steps. Now the variants compose:

# All variants share the same projection — declare it once on the frame.
af = af.projection.set(schedule=sched)

# Vanilla UL
b_vanilla = af.projection.rollforward(states={"av": af["av_init"]})
add_premium(b_vanilla, "av")
add_ul_charges(b_vanilla, "av")
add_credit(b_vanilla, "av")
add_floor(b_vanilla, "av")

# UL with a rider (extra charge between Admin and Interest)
b_rider = af.projection.rollforward(states={"av": af["av_init"]})
add_premium(b_rider, "av")
add_ul_charges(b_rider, "av")
b_rider["av"].charge(af["rider_rate"], label="Rider")
add_credit(b_rider, "av")
add_floor(b_rider, "av")

# UL with no admin fee (e.g. a high-AV band)
def add_coi_only(builder: RollforwardBuilder, state: str) -> None:
    builder[state].deduct_nar(
        pl.col("coi_rate"),
        death_benefit=pl.col("sum_assured"),
        label="COI",
    )

b_no_admin = af.projection.rollforward(states={"av": af["av_init"]})
add_premium(b_no_admin, "av")
add_coi_only(b_no_admin, "av")
add_credit(b_no_admin, "av")
add_floor(b_no_admin, "av")

The ordering is just Python, so it's transparent and trivially testable. Use compiled.explain() after compile to verify the chain matches your spec for each variant.

Conditional Inclusion

To include or exclude a step based on a configuration flag, use ordinary if:

def build_variant(
    *,
    af: ActuarialFrame,
    has_rider: bool,
    has_admin: bool,
) -> RollforwardBuilder:
    b = af.projection.rollforward(states={"av": af["av_init"]})
    add_premium(b, "av")
    b["av"].deduct_nar(
        af["coi_rate"],
        death_benefit=af["sum_assured"],
        label="COI",
    )
    if has_admin:
        b["av"].charge(af["admin_rate"], label="Admin")
    if has_rider:
        b["av"].charge(af["rider_rate"], label="Rider")
    add_credit(b, "av")
    add_floor(b, "av")
    return b


b_full = build_variant(af=af, has_rider=True, has_admin=True)
b_no_rider = build_variant(af=af, has_rider=False, has_admin=True)
b_minimal = build_variant(af=af, has_rider=False, has_admin=False)

This is the recommended pattern for parameterising product flavours. Configuration lives in Python; the chain is determined unambiguously at build time; compiled.fingerprint() differs across variants and you have a paper trail of which combinations exist.

Per-Period Conditional Application

The pattern above is per-policy / per-build — the step either runs every period or never. To gate a step on a per-period condition (e.g., a rider that only applies after year five), build the rate column with the condition baked in:

b = af.projection.rollforward(states={"av": af["av_init"]})
add_premium(b, "av")
add_ul_charges(b, "av")

# Gate element-wise over the per-period list: the rider rate applies from year 5 on.
af.rider_rate_gated = pl.col("rider_rate_full") * pl.col("year_index").list.eval(
    (pl.element() >= 5).cast(pl.Float64)
)

b["av"].charge(af["rider_rate_gated"], label="Rider")

# 0.0 for years 1–4, then the full rider rate from year 5 on:
print(af.collect().get_column("rider_rate_gated").to_list()[0][:6])

A zero-rate .charge does nothing (multiplies by 1.0), so periods where the condition is False pass through unchanged. The same construction works for .add, .subtract, .deduct_nar, and .grow — produce a list-column expression that is zero (or one, for growth) when the step should sit out for a given period.

For .ratchet, the when= argument is already a per-period boolean indicator. Like other step args, it must be a single column reference — so compose any compound condition into a column on the frame first:

b = af.projection.rollforward(
    states={"av": af["av_init"], "guarantee": af["guarantee_init"]}
)
b["av"].grow(af["fund_return"], label="Fund Return")

# Compose the per-period mask element-wise (both are list[bool] columns):
af.ratchet_mask = (
    pl.col("anniv_mask").cast(pl.List(pl.Int8))
    * pl.col("rider_active").cast(pl.List(pl.Int8))
).list.eval(pl.element() > 0)

b["guarantee"].ratchet(
    to=pl.col("av@eop"),
    when=af["ratchet_mask"],
    label="GMDB Ratchet",
)

# True only at anniversaries where the rider is active:
print(af.collect().get_column("ratchet_mask").to_list()[0])

Sharing Schedules and Initial Values

Variants typically share the same projection and initial-value expression. Declare the projection on the frame, then pass the frame to each variant builder:

def build_ul(
    *,
    af: ActuarialFrame,
    states_init: dict[str, pl.Expr],
    has_rider: bool = False,
) -> RollforwardBuilder:
    b = af.projection.rollforward(states=states_init)
    add_premium(b, "av")
    add_ul_charges(b, "av")
    if has_rider:
        b["av"].charge(af["rider_rate"], label="Rider")
    add_credit(b, "av")
    add_floor(b, "av")
    return b


# 20-year and 30-year variants share the same chain but different projections.
# Each variant lives on its own frame because the projection is part of the frame.
sched_20y = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=240, frequency="1M"
)
sched_30y = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=360, frequency="1M"
)
af_20 = af.projection.set(schedule=sched_20y)
af_30 = af.projection.set(schedule=sched_30y)
b_20 = build_ul(af=af_20, states_init={"av": af_20["av_init"]})
b_30 = build_ul(af=af_30, states_init={"av": af_30["av_init"]})

Different Schedules naturally produce different compiled.fingerprint() values — the time axis is part of the model's identity.

Auditing Across Variants

Each compiled variant has its own fingerprint. Persist them alongside results:

af = af.projection.set(schedule=sched)
variants = {
    "vanilla": build_ul(af=af, states_init=init, has_rider=False),
    "with_rider": build_ul(af=af, states_init=init, has_rider=True),
}
compiled = {name: compile_rollforward(b) for name, b in variants.items()}

for name, c in compiled.items():
    print(f"{name:<12}  {c.fingerprint()}")
vanilla       sha256:5b5d5ab69eaa8bcd30c3269128c4899f37f565661d921380cd31594263b43f1f
with_rider    sha256:e48dbbe07a44a1ffae3b6e892fd66fa7b4d1c3576b334d9a718255f388200d7f

Distinct version stamps make structural changes self-evident. If a release said it would ship the vanilla variant but the production model's fingerprint() matches with_rider, the wrong product is in the valuation — and you see it before the AOM does.

If the value changes between two releases but the variant name didn't, the model structure drifted between them — a more reliable signal than diffing source files.

What This Pattern Does Not Do

  • No mutate-after-compile. CompiledRollforward is frozen. To change the chain you build a fresh builder, possibly using the same helpers, then call compile_rollforward again. Caching is your responsibility — typically by keying on the helper inputs.
  • No automatic step deduplication. If two helpers both call add_premium, you get two Premium steps in the chain (and the premium gets added twice). Each step runs once per period; compile_rollforward does not collapse duplicates.
  • No structural diff helper. Compare two variants by print(c.canonical_form()) and diff the printed dicts.