Implementation Notes#
Status: Draft, 2026-08-15. Built from
products/income_protection/technical-notes.md;
the product it implements is specified in
product-spec.md.
This is a mechanics demonstration, not a pricing or reserving result. The contractual mechanics are sourced — the deferred period menu, the two-band maximum-benefit formula, escalation capped at 10% with a ×1.5 premium multiplier continuing in claim, waiver of premium from benefit start, linked claims within 52 weeks, expiry without value. Every rate is a std standardization: the CMI IP11 Series is restricted to CMI Authorised Users R1 R2 R5 REG-R22, so the inception, recovery and in-claim mortality rates shipped here are proxies shaped like IP11 that carry no CMI authority, and the premium is a placeholder. Replace them with a licensed basis first.
Run it#
python products/income_protection/run.py # the active-lives anchor cell
python products/income_protection/run.py 2 # the claims-in-payment worked example
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/income_protection/IP_UK_S")
model.Projection[1].result_cf()
The only multiple-state model in the library#
Every other model here runs a single in-force probability down through decrements. This one carries a three-state population:
inception ι recovery ρ
H ─────────────────────────▶ S ─────────────────────────▶ H (return_to_h basis)
│ │
│ mortality q_H │ mortality q_S
│ lapse w │
▼ ▼
D / lapsed D
That structure is not decoration: it is the structure the CMI’s own graduations use, and it is why income protection has two experience bases rather than one — claim inception rates out of H, and claim termination rates out of S, the latter split into recovery and death. Lapse is a further exit from H; lives in claim never lapse std, since their premiums are waived and the benefit in payment is the most valuable thing they own.
The in-claim population is two-dimensional#
Termination rates depend on how long the claim has already run — 40% a year at duration
one falling to 5% from duration five in the shipped basis — so the model tracks
l_S(t, z) cohort by cohort. Collapsing that to a single bucket with a
duration-independent termination rate materially misstates claim run-off, and is the
notes’ first-listed pitfall: the duration gradient is the defining feature of income
protection terminations.
sick_cohorts(t) holds the whole vector for one month and is the model’s only
list-valued cells. The alternative — a two-argument pols_sick_dur(t, z) recursion —
would be proj_len() × max_dur() separate cells, 130,000 of them on the anchor cell,
each with its own cache entry. Keeping the vector in one cells per month makes it
proj_len() cells with a loop inside; pols_sick_dur(t, z) reads an element out of it,
so the notes’ two-dimensional object is still addressable by name. claim_rate_vectors()
is the same trick for the three per-duration rate vectors, and takes the anchor cell from
about sixteen seconds to five.
The shipped termination basis suppresses the age dimension std. IP11 is
two-dimensional in age and duration, with claimant mortality duration-dependent to five
years and age-only beyond, and it also has a “run-in” of increasing recovery rates over
the first weeks of claim for the shorter deferred periods, which annual duration-year
granularity smooths away. A licensee restoring both dimensions changes
termination_table.csv and two lookups, and nothing else.
The deferred period is in the inception basis, not a fourth state#
ι is a claim payment inception rate specific to the policy’s deferred period —
exactly the quantity the CMI publishes per deferred period R1 — so:
a sickness spell that recovers inside the deferred period never leaves H;
a life sick but not yet in payment stays in H and keeps paying premiums, which is what the contract’s waiver-from-payment-start convention says.
No separate “sick, not yet in payment” state is needed and the lag between onset and
payment is absorbed into the calibration of ι. Dual deferred periods and sick-pay-linked
NHS/teacher deferreds would need spell-level modelling and are out of scope.
Two kinds of model point, and where recoveries go#
The notes give two calculations. The model point’s recovery_basis column says which one
a cell runs, and status says which state the population starts in:
|
Recovered lives |
What it is |
Model points |
|---|---|---|---|
|
re-enter H, resume paying premiums, are again exposed to inception |
the active-lives projection, the notes’ processing order step 5 |
1, 3, 4, 6, 7 |
|
leave the model |
the disabled-life annuity of the claims-in-payment valuation, and the basis the worked example is computed on |
2, 5 |
Keeping it a column rather than deriving it from status matters, because the choice is
a valuation question and not a property of the cell: a claims-in-payment reserve is the
disabled-life annuity, but a full contract-boundary best estimate for the same policy
would carry the post-recovery active phase as well. Model point 7 is point 2 on
return_to_h, and its benefit PV is about 2% higher, because recovered lives can and do
claim again.
Linked claims limitation. Contractually a same-cause recurrence within 52 weeks restarts payment with no new deferred period, and returning recovered lives to the standard inception basis ignores that — it understates re-inception at short horizons. The refinement is a post-recovery flag carrying a loaded inception rate for twelve months; it is not implemented, and the notes name the same gap.
Benefit in arrears, and the month a claim starts#
A claim incepting at the end of month t seeds cohort z = 1 and receives its first
payment at the end of month t + 1. So the benefit is paid on pols_sick_surv(t) — the
cohorts already in payment at the start of the month that survived it — and not on
pols_sick(t) plus new inceptions. Paying the new inceptions would hand over a full
month’s benefit at the instant payment starts and break the equivalence with the
inception-annuity decomposition of the same projection.
The contractual daily pro-rating of partial claim months is replaced by whole-month payment std: a life recovering mid-month receives nothing for that month here, and a pro-rated amount in reality.
Expiry truncates everything#
All cover and any claim in payment terminate at the policy end date with no value.
pols_maturity(t) is that termination, non-zero only in the last month, and it is what
makes the in-force roll-forward close. An untruncated disabled-life annuity materially
overstates the liability for claims incepting near expiry — model point 5 is a claim at
duration 30 months on a policy with 15 years to run, and its benefit stream stops dead at
proj_len().
Amount payable is not the chosen benefit#
Offsets against other income, the £1,500 minimum benefit guarantee and proportionate
benefits on a partial return to work all move the amount actually paid away from the
benefit the policyholder chose. The base run sets AP = B through ap_ratio = 1 and
claim_severity = 1 std, which overstates outgo wherever the maximum-benefit
formula bites and understates nothing, since AP ≤ B always. A portfolio calibration
sets one or both below 1 from claims experience.
benefit_max_pp() implements the contractual two-band maximum — 65% of earnings to the
£60,000 breakpoint, 50% above it, capped at £20,000 a month and floored at the £1,500
guarantee — and check_benefit_max() asserts every model point’s chosen benefit is
inside it, with the 90% tolerance that stops a small fall in earnings cutting an in-force
benefit. Unlike the other two checks that one is a validation of the model point
rather than an identity of the projection: a benefit above the maximum is a policy that
could not have been written, and the underwriting record is on the model point precisely
so that it can be checked.
Discounting, which the rest of the library does not do#
Every other model in this library projects undiscounted gross liability cash flows
and leaves discounting to the layer that consumes them. This one also carries
disc_factor(t), pv_benefits() and annuity_dis(), because the notes’ worked example
is a present value and because the disabled-life annuity is the object a claims-in-payment
reserve is quoted as.
They are a companion, not part of the projection: no line of result_cf() is
discounted, and disc_rate is the worked example’s flat 3% std, not a valuation
basis. A Solvency UK best estimate discounts these same cash flows on the PRA risk-free
term structure R7 REG-R1; the claims-in-payment element is matching-adjustment
eligible where it is organised and managed separately R8 REG-R2, which is precisely
the exit-basis disabled-life annuity above.
Inputs are external files#
The five input CSVs live in this directory, beside run.py — not inside the model
folder. IP_UK_S/ holds nothing but formulas:
products/income_protection/
model_point_table.csv <- inputs live here
inception_table.csv
termination_table.csv
mort_table.csv
lapse_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
IP_UK_S/ <- formulas only
__init__.py (model docstring)
_system.json
Data/__init__.py (reads the CSVs, once per model)
Projection/__init__.py (the by-policy projection)
This follows lifelib’s annuallife/TradLife_A. Projection is parameterized by
point_id, so the CSV readers live in an unparameterized Data Space and each file
is read once per model rather than once per model point; a test counts the reads.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The two experience bases are separate files because they are separate CMI publications and are parameterized differently — inception by sex, occupation class, deferred period and age, exactly as the IP11 Series names its tables; terminations by claim duration.
File |
Contents |
Provenance |
|---|---|---|
|
Seven model points: the active-lives anchor cell (M35 / OC1 / DP26 / £2,000 a month to age 65, RPI-escalating), point 2 is the claims-in-payment worked example (the same cell, level cover, in claim at duration 0, |
anchor cell std, technical notes |
|
Annual claim inception rates at pivot ages 30–64, by sex × occupation class × deferred period, with a |
the M / OC1 / DP26 pivots are the notes’ std proxy table verbatim; every other cell is those pivots times flat std factors (deferred period 2.60/1.80/1.45/1.00/0.60 for 4/8/13/26/52 weeks, occupation 1.00/1.35/1.90/2.60, female 1.25) — not IP11 values |
|
Annual recovery and in-claim mortality by claim duration year, 40/25/15/10/5% and a flat 3% |
the notes’ std proxy table verbatim |
|
Active-life mortality by sex and age 16–75 |
std proxy shaped like the ONS national life tables — not ONS values |
|
Annual lapse by policy year, 10 / 8 / 6 / 6 / 6 / 4 % |
std; no public UK IP lapse study was retrieved, so this table has no anchor at all |
Inception rates are interpolated linearly between pivot ages and extrapolated linearly
beyond them (floored at zero), which is what the notes specify — note the contrast with
CI_UK_S, whose notes specify log-linear interpolation of its pivot table. Two products,
two rules, and each model follows its own notes.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE: pols_* for
population counts, plural nouns for cash flows, *_rate for annual rates and *_rate_mth
for monthly ones, *_pp for per-policy amounts, claims(t, kind) with an uppercase
kind string. The full symbol mapping lives in the Projection Space docstring. Three
cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
|
|
|
An income stream rather than a lump sum, but reached through the library’s one benefit-outgo cells. The other two kinds are zero and say so: no death benefit on this composite, and no surrender value at any time |
|
the two arguments |
Different clocks, never mixed: rates out of S take |
Standardizations used#
Everything in this list is std: the entire experience basis — inception, recovery,
in-claim mortality, active-life mortality and lapse — and the factors that spread the
inception pivots across sex, occupation class and deferred period; the linear pivot
interpolation and its extrapolation; the age-suppression of the termination basis; the
premium; maintenance £60 a year and claim management £300 a year, both inflating at 3%;
the flat 3% RPI snapshot and the ×1.5 premium multiplier; the premium-shock lapse
multiplier M_esc; the economic-cycle overlay M_cycle (held at 1, a scenario axis
rather than a calibrated assumption); AP = B and k = 1; whole-month benefit payment
in place of daily pro-rating; the annual-to-monthly conversions; death-then-lapse-then-
inception as the order out of H and recovery-then-death out of S; and, on an in_claim
cell, treating the valuation date as a policy anniversary so the escalation clock
restarts there.
Tests#
tests/test_income_protection_uk.py asserts the notes’ three-month claims-in-payment
worked example to the penny including its present values, the month-one active-lives
figures beside it, the duration gradient and what collapsing it would cost, that premiums
come from H alone, the in-arrears payment timing, expiry truncation, the two recovery
bases against each other, the three-state population identity, the two-band benefit
maximum, and that death and lapse pay nothing.
python -m pytest tests -q