Skip to content

Step Composition

The Product Development Workflow

Real product development isn't linear. You build a base product, then explore variations: "what if we add a rider charge?", "what if we remove the admin fee?", "what if we replace flat crediting with an index strategy?"

In a spreadsheet, each variation is a new copy of the model. In code-based tools, each variation is a new function. Both approaches lead to duplicated logic where a single typo in one variant produces a silent error.

Building Variants from a Base

The rollforward builder is immutable — every method returns a new builder without modifying the original. This means you can create the base product once and derive all variations from it:

from gaspatchio_core.rollforward import Step

# Define the base UL product
base_ul = (
    af.projection.rollforward(initial=af.av_init)
    .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)
)

# Variation 1: Add a rider charge before interest
ul_with_rider = base_ul.insert_before(
    "Interest", Step.charge(af.rider_rate, "Rider Fee")
)

# Variation 2: Replace percentage admin with flat dollar fee
ul_dollar_admin = base_ul.replace(
    "Admin", Step.subtract(af.admin_dollar, "Admin ($)")
)

# Variation 3: Remove admin entirely
ul_no_admin = base_ul.remove("Admin")

# Variation 4: Add surrender charge at the end
ul_with_surrender = base_ul.append(
    Step.charge(af.surrender_rate, "Surrender Charge")
)

# Run all variants in one collect()
af.av_base = base_ul
af.av_rider = ul_with_rider
af.av_dollar = ul_dollar_admin
af.av_no_admin = ul_no_admin
af.av_surrender = ul_with_surrender
result = af.collect()

Each variant differs from the base by exactly one change. The diff is explicit and auditable — no scanning through 50 lines of near-identical code to spot what's different.

Composition Methods

Method What It Does
.insert_before(label, step) Add a step before the named step
.insert_after(label, step) Add a step after the named step
.remove(label) Remove the named step
.replace(label, step) Swap one step for another
.prepend(step) Add a step at the beginning
.append(step) Add a step at the end

All composition methods return a new builder — the original is never modified.

Addressing Steps by Label

Steps are addressed by their label. Every step has one:

  • If you provide a label: Step.charge(af.admin_rate, "Admin") → label is "Admin"
  • If you omit it: Step.charge(af.admin_rate) → label is auto-generated as "Charge(admin_rate)"

Labels must be unique within a builder. If you try to add a step with a duplicate label, you get an immediate error:

ValueError: Duplicate label 'Admin' in rollforward.
Already used at step 3.

For models where you plan to compose, use explicit labels — they're stable even if you rename columns.

The Step Factory

The Step class provides factory methods for creating steps to use with composition:

from gaspatchio_core.rollforward import Step

Step.add(af.premium, "Premium")
Step.subtract(af.expense, "Expense")
Step.charge(af.rate, "Fee")
Step.grow(af.rate, "Interest")
Step.grow_capped(af.rate, floor=0.0, cap=0.12, label="Index Credit")
Step.deduct_nar(af.coi_rate, death_benefit=af.sum_assured, label="COI")
Step.floor(0.0)
Step.cap(1_000_000.0, "Max AV")
Step.add_if(af.condition, af.amount, "Conditional Add")
Step.charge_if(af.condition, af.rate, "Conditional Charge")

Inspecting Variants

Use .explain() to verify the step order of any variant:

print(ul_with_rider.explain())
Rollforward: initial=av_init, 6 steps

  Step  Operation              Label        Formula
  ----  ---------------------  -----------  ----------------------------------
  1     Add(premium)           Premium      av[t] = av[t] + premium[t]
  2     DeductNAR(coi_rate)    COI          av[t] = av[t] - coi_rate[t] * ...
  3     Charge(admin_rate)     Admin        av[t] = av[t] * (1 - admin_rate[t])
  4     Charge(rider_rate)     Rider Fee    av[t] = av[t] * (1 - rider_rate[t])
  5     Grow(interest_rate)    Interest     av[t] = av[t] * (1 + interest_rate[t])
  6     Floor(0)               Floor(0)     av[t] = max(av[t], 0)

The Rider Fee appears at step 4, exactly where insert_before("Interest", ...) placed it.