Implementation Notes#
Status: Draft, 2026-08-15. Built from
products/term_assurance/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 decreasing-shape amortization, the family income benefit as an annuity-certain, terminal illness as an acceleration rather than an extra benefit, the absence of any surrender value, the indexation caps. Every quantitative assumption is a std standardization: no UK insurer publishes premium rate tables (pricing is quote-driven, and only the £5/month minimum is public [S5]), and the current CMI “16” Series assured lives tables are subscriber-restricted R11. Replace them with company data and licensed tables before drawing any conclusion from the numbers.
Run it#
python products/term_assurance/run.py
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/term_assurance/Term_UK_A")
model.Projection[1].result_cf()
Projection takes a point_id; Projection[1] is the worked-example anchor cell.
result_cf() returns a tidy DataFrame indexed by policy year t with one column per
cash flow line.
The model and its Projection Space both carry docstrings — model.doc describes the
product and the projection basis, and model.Projection.doc holds the full mapping
between the technical notes’ symbols and the cells names.
No tail states — the structural contrast with Term_US_A#
Policy year t runs 1 … proj_len() = policy_term(), and there is nothing after
it. Cover ceases at the end of the term with no maturity value, no renewal and no
conversion [S1] [S2] [S6] [S8] R8.
That is the one difference from this library’s U.S. term model that changes the shape
of the liability rather than a parameter. Term_US_A runs a post-level-term phase:
premiums jump to ART rates at the end of the level period, a shock lapse takes 80% of
the block, the survivors are loaded for mortality deterioration, and coverage continues
to attained age 95. None of that exists here — no phase, no jump_ratio, no
shock_lapse_rate, no plt_mort_factor, no conv_rate. The notes list importing a
U.S.-style post-level-term tail as a modelling pitfall precisely because it materially
misstates UK term liabilities.
What has no U.S. analogue, in the other direction, is the family income benefit ledger below.
Annual, not monthly#
The notes’ base grid is annual, with a monthly variant described but not implemented,
so premium_mode is inert. Two annual-grid approximations are wired in and are, per
the notes, an offsetting pair:
the decreasing shape’s death benefit is the mid-year balance
B(12(t−1) + 6);premiums are annual in advance, with no allowance for premiums ceasing at a mid-year death or lapse, which slightly overstates income.
The notes are explicit that applying a further half-year premium adjustment on top of the mid-year claim timing would double-count the correction. The monthly grid is the arbiter of both.
Inputs are external files#
The four input CSVs live in this directory, beside run.py — not inside the model
folder. Term_UK_A/ holds nothing but formulas:
products/term_assurance/
model_point_table.csv <- inputs live here
mort_table.csv
select_factor_table.csv
lapse_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
Term_UK_A/ <- 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, which keeps its input file beside the
model and reads it at run time. It is the opposite of basiclife/BasicTerm_S, which
stores its inputs inside the model through modelx’s IOSpec machinery — hence no
_data/ directory and no embedded values here at all.
Read once, in Data#
Projection is parameterized by point_id, so every Projection[N] is a separate
ItemSpace with its own cells cache. Readers placed there would re-read every file for
every policy. They live instead in an unparameterized Data Space, which
Projection references as data — so each file is read once per model no matter how
many policies are projected. A test counts the reads.
Data.input_dir() resolves the location from _model.path.parent when the model is
read, so it works wherever the repository is checked out.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The trade-off: the model is not portable on its own. Copy Term_UK_A/ without the
CSVs and it will read fine, then fail on first evaluation. What you gain is that a diff
of the model shows logic changes only, and an input can be swapped in place — point
Data.mort_table_file at another same-schema file and the projection follows, with no
formula change. Tests cover both halves of that bargain.
File |
Contents |
Provenance |
|---|---|---|
|
Eight model points. Point 1 is the worked-example anchor cell (M35 / non-smoker / level / 25-year / £150,000 / £12.00 per month); points 2–8 exercise the decreasing and FIB shapes, FIB commutation, indexation, joint first death, the select mortality basis with waiver of premium, and one policy already in force at duration 5 |
anchor cell std, technical notes’ worked example |
|
Mortality including terminal illness by sex, smoker status and age 18–100, with a |
M/N ages 35–37 are the notes’ illustrative worked-example vector; every other cell is a 9% p.a. geometric extension in age with a 2.2 smoker and a 0.70 female factor — all std, not a published table |
|
Select-duration factors, 0.55 grading to 1.00 over a 5-year select period |
|
|
Annual lapse by policy year, 10 / 8 / 7 / 5 / 6 / 4 % |
std, anchored to the FCA 5% in-force average and the clawback-spike pattern R9 |
Two mortality bases, and why there are two#
UK assured lives tables are select tables — TMNL16/TFNL16 have a 5-year select
period, AM92 a 2-year one R12 — so the mortality interface has to accept a rate
depending on duration since entry as well as attained age. mort_rate_base(t, life)
and select_factor(t) provide it.
But the notes’ worked example is quoted as three applied rates, q(1) = 0.00055,
q(2) = 0.00060, q(3) = 0.00065, described as illustrative values in the shape of a
non-smoker temporary assurance table and explicitly not taken from any CMI table.
Three numbers rising at 9% a year are not consistent with a graduated select structure,
where the wearing-off of selection alone moves the applied rate faster than that.
Forcing them onto one would mean either a back-solved ultimate curve that is nearly
flat at ages 35–37 or shipped cells that deviate from the notes.
So both readings ship, as a model point column — the same device SPIA_US_S uses for
its table / scenario split:
|
What |
Model points |
|---|---|---|
|
the rate actually applied — no select factor, no proxy scaling |
1–6, 8 |
|
an ultimate basis, multiplied by |
7 |
Neither is “correct” — both are standardizations, and the point of shipping both is
that the gap cannot be closed silently in either direction. A production run replaces
the two CSVs with licensed tables and uses the select basis; no formula changes.
The family income benefit ledger#
A death in month k on the fib shape triggers N − k monthly instalments of I, in
arrears, ending at month N. Those instalments are an annuity-certain: once the
claim is admitted they run to the end of the term regardless of any life [S6] [S8]. So
the in-payment stream is decremented by neither mortality nor lapse — only new claims
carry l(t).
fib_cum(t) is the ledger: the expected streams already in payment at the start of
year t. With deaths at mid-year on the annual grid std,
claims(t, "FIB") = I × [6 × D(t) + 12 × FIBcum(t)]
and one death in year s generates 6 + 12(n − s) instalments in total, which is
exactly N − k at k = 12(s−1) + 6. check_fib_ledger() rebuilds each year’s
instalment count straight off the death vector, with no reference to the recursion, and
asserts the two agree; a ledger decremented by mortality — the notes’ pitfall — or one
paying only the year-of-death instalments fails there.
The optional commutation module replaces a proportion of the streams with a lump sum,
the present value of the remaining instalments at the std snapshot rate
r_c = 3%. Contractually the insurer reduces the remaining instalments “fairly and
reasonably” [S6] [S8] and no insurer publishes the basis. Base take-up is zero; model
point 4 exercises the other extreme, and its total outgo is materially lower than point
3’s because commuting at 3% is worth less than paying the instalments out undiscounted.
Terminal illness is not an extra benefit#
Terminal illness is a 100% acceleration of the death benefit under a two-limb
12-month definition [S1] [S6] [S8]: one decrement, one payment. There is no ti_rate
anywhere in the model, and mort_rate(t) is the combined death-and-terminal-illness
rate — the 16-Series tables the shipped table proxies are graduated on that basis
R10. Adding a separate terminal-illness decrement double-counts claims, which is the
notes’ first-listed pitfall.
claims_lapse is a column of zeros, deliberately#
There is no surrender value and no paid-up value at any duration [S1] [S6] [S8] R8, so a
lapse is a pure decrement: it moves pols_if and pays nothing. claims(t, "LAPSE")
exists, returns zero, and appears in result_cf() as a zero column, because the notes
list a non-zero lapse row as a pitfall imported from US models with cash surrender
values. A column of zeros states the product fact; a missing column would only hide it.
pols_maturity — the one cells the notes do not define#
The notes give the roll-forward as l(t+1) = l(t)(1−q)(1−w) and, separately, terminate
everything at t = n. Those two do not reconcile in the final policy year: its
survivors neither die nor lapse — their cover simply runs out — so the roll-forward
appears to lose lives with no cause. pols_maturity(t) names that quantity, zero in
every year but the last, which makes the identity close exactly:
pols_if(t) − pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_maturity(t)
This is bookkeeping, not a new assumption — the value is fully determined by the notes’
own rules — and it is not a maturity benefit: the amount paid is nil. The name follows
BasicTerm_S.pols_maturity, and Term_US_A carries the same cells for the same reason.
Modules that are off in the base run#
Four of the notes’ optional constructions are implemented and switched off, so the base run reproduces the worked example while the machinery stays visible and testable.
Module |
Switch |
Off value |
What it does |
|---|---|---|---|
Selective lapsation |
|
|
Loads persisters’ mortality by |
Rebroking |
|
|
Multiplies the lapse rate by |
Commission clawback |
|
|
Recovers |
Waiver of premium |
model point |
|
A two-state incidence/recovery chain on the premium-paying population, plus a premium loading. Both its incidence basis and its extra premium are std placeholders — no public UK incidence basis for the work-tasks definitions appears in the sources |
The waiver module makes two simplifications worth naming: incidence in year t
produces waiver from year t + 1, which is the annual grid’s reading of the 26-week
deferred period [S1] std; and mortality and lapse are assumed independent of the
waiver state std, which is what lets the waived population be carried as a
fraction of the in-force rather than as its own decrement.
The indexation option is a model point flag rather than a module switch. Its base run is deterministic and always accepts, as the notes specify, with a flat 3% RPI scenario std — so cover grows at 3% a year and premium at 4.5%, the ×1.5 factor [S1] [S2] [S6]. One consequence: with acceptance certain, the rule removing the option after three consecutive declines [S1] [S6] (two at one insurer [S8]) is never reached and is not implemented. Indexation is restricted to the level shape [std scope], since no fetched insurer offers indexed decreasing cover.
Sign convention#
The notes’ CF(t) is already income positive — they write “+ = inflow” — which is
the library-wide sign of net_cf. So unlike WholeLife_US_A, SPIA_US_S and
DIA_US_S, whose notes print the stream outgo-positive and which therefore publish a
liability_cf companion column, there is one stream here under one name.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S wherever that model has an analogue:
pols_* for policy counts, plural nouns for cash flows, *_rate for rates, *_pp for
per-policy amounts, claims(t, kind) with an uppercase kind string, and
pols_if_at(t, timing) for the within-year in-force reads. The technical notes use
compact actuarial symbols; the full mapping lives in the Projection Space docstring.
Four cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The notes use one symbol for the per-life table rate and for the decrement applied to the policy, which on a joint first-death policy is |
|
|
|
|
|
The first two are inside |
(no symbol) |
|
See above |
Standardizations used#
Everything in this list is std: the whole mortality table and its select factors;
the 75% proxy scaling; the lapse duration table; the premium itself (£12.00 per month);
acquisition expense £150; maintenance £30 inflating at 3%; claim expense £250; initial
commission 150% of annualized premium and renewal 2.5% from year 2; the clawback
formula; the FIB commutation rate of 3%; the decreasing shape’s 6% schedule rate, its
j_m = (1+j)^(1/12) − 1 monthly convention and its mid-year death benefit; the
selective-lapsation and rebroking constructions; the waiver incidence, recovery and
premium loading; the flat 3% RPI scenario; and death-before-lapse as the processing
order.
Tests#
tests/test_term_assurance_uk.py asserts the notes’ three-row worked example to the
penny and the in-force column to six decimals, the B(60) = £134,588 decreasing-schedule
anchor and the j_m convention behind it, the FIB ledger against an independent rebuild
and against the annuity-certain total, that the FIB stream is not decremented by
mortality, expiry with no tail states, the roll-forward identity, the joint first-death
decrement, indexation’s ×1.5 premium factor, the two mortality bases, the four off-by-
default modules in both positions, and that a lapse pays nothing.
python -m pytest tests -q