Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/guaranteed_ul/technical-notes.md;
the product it implements is specified in
product-spec.md. Those notes build on
the universal life chassis, and so does this model — see
products/universal_life/ and its
technical notes.
This is a mechanics demonstration, not a pricing or reserving result. The contractual elements — the 25% premium load and the $5.50 monthly per-policy charge, the 2.0% guaranteed credited rate, the 5.0%/3.0% loan rates, the 61-day grace period, the return-of-premium percentages and their 40%-of-face cap, the in-force test on the shadow account net of indebtedness, the cessation of charges at attained age 121 — are sourced from the retrieved carrier documents behind the product spec. Every shadow-account parameter is a
[std]standardization: no carrier publishes them and no specimen policy form was retrievable. So are the mortality tables, the lapse tables, the dynamic-lapse formulas, the exercise rates and every expense. Replace them with company data before drawing any conclusion from the numbers.
Run it#
python products/guaranteed_ul/run.py
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/guaranteed_ul/ULSG_US_S")
model.Projection[1].result_av()
Projection takes a point_id; Projection[1] is the worked-example anchor cell.
There are four result tables, all DataFrames indexed by policy month t:
|
the two account values, in the worked example’s own column order |
|
the guarantee diagnostics: net amounts at risk, |
|
the liability cash flows |
|
the decrements |
The model and both Spaces carry docstrings — model.doc describes the product and the
projection basis, model.Projection.doc holds the full mapping between the technical
notes’ symbols and the cells names, and model.Data.doc explains the input arrangement.
Monthly, and t = 1 is not policy month 1#
t counts policy months from the start of the projection, 1-based.
proj_len() = 12 × (121 − age_at_entry()) − duration_mth_init() — the notes’ maximum
projection length, running to attained age 121 where premiums and all charges cease.
The technical notes index the same months by their absolute policy month number,
starting at duration_months + 1. Model point 1 is an in-force cell with
duration_mth = 300, so the worked example’s months 301–305 are t = 1 … 5 here,
with duration_mth(t) = 300 + t − 1. Getting that offset wrong is the easiest way to
misread the golden table.
State variables the notes define at t = 0 — AV_0, SG_0, L_0, CumPrem_0,
l_0 = 1, g_0 = 0 — are the t == 0 branch of the corresponding recursion.
Inputs are external files#
The eight input CSVs live in this directory, beside run.py — not inside the model
folder. ULSG_US_S/ holds nothing but formulas:
products/guaranteed_ul/
model_point_table.csv <- inputs live here
coi_rates.csv
corridor_factors.csv
mort_table.csv
class_factor_table.csv
lapse_table.csv
surr_charge_table.csv
rop_table.csv
run.py
README.md
ULSG_US_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, which keeps its inputs beside the model
and reads them 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.
Data.input_dir() resolves the location from _model.path.parent when the model is
read, so it works wherever the repository is checked out. Each table has a filename
Reference and a reader Cells, both on Data:
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The trade-off: the model is not portable on its own. Copy ULSG_US_S/ 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 edited or swapped in
place — point Data.mort_table_file at another same-schema file and the projection
follows, with no formula change.
File |
Contents |
Provenance |
|---|---|---|
|
Four points, all on the anchor cell M60 / StdNT / $500,000. Point 1 is the worked-example cell (in force at 300 months, AV $2,400, SG $118,000, level $10,800); point 2 is the same policy from issue on the notes’ behavioural basis; point 3 funds the lifetime guarantee with one premium; point 4 elects a guarantee to age 90 and underfunds it |
attributes from the notes’ model point table; the opening cumulative premium and the extra points std |
|
Guaranteed annual maximum COI per $1,000 by attained age, M / StdNT only |
std Perks curve, |
|
GPT corridor factors by attained age: the IRC 7702(d)(2) applicable percentages in full, 250% to age 40 down to 100% from age 95 |
sourced R4; never binds on a thin-account design |
|
Best-estimate annual mortality by attained age |
std the same curve at 72% of the guaranteed basis, not the 2015 VBT the notes recommend |
|
Rate-class factors for the four NT and two tobacco classes S4 |
|
|
Base annual lapse by policy year: 4.0 / 3.0 / 2.5 / 2.0 / 1.5 / 1.0 / 0.75% |
|
|
15-year linear schedule, $18 per $1,000 of initial face |
std (spec note) |
|
Refund windows at anniversaries 20 and 25 |
Neither mortality table is a published one. The 2017 CSO and 2015 VBT families are licensed and the notes forbid hard-coding them; both files here are illustrative and swappable by repointing their Reference.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE wherever those
models have an analogue, and this library’s own UL_US_S — the chassis this
product is built on — everywhere else: pols_* for policy counts, av_* for account
values, plural nouns for cash flows, *_rate for rates, *_pp for per-policy amounts,
timing and kind string arguments, result_cf / result_pols / result_av,
check_av_roll_fwd / check_margin. Names introduced here are the ones guaranteed UL
genuinely adds: the shadow account (sg_*), the guarantee tests, the grace counter, the
forgone deduction and the return-of-premium endorsement.
result_cf() follows the library-wide column set: lower snake case, indexed by t,
pols_if the start-of-month weight applied to that same row, and the cash flow columns
summing to net_cf income-positive. The surrender column is claims_lapse, named
for the "LAPSE" kind that produces it, and partial withdrawals are withdrawals,
a column of their own rather than a claim. Every check_* here takes no argument and
returns a bool over the whole projection, so one test can call the same check across
the library.
The full notes-symbol → cells-name table lives in the Projection Space docstring, and
it covers every symbol the notes define, model point attributes included. Nine cases
needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The name |
|
|
These notes define |
|
|
Measured after the expense charges, one step later than the chassis’ |
|
|
The notes’ |
|
|
Split from |
“guarantee active” |
|
The notes use the same words for two tests at different measurement points |
|
|
A charge against the account value, i.e. income — not |
|
|
A withdrawal is a payment on the owner’s election out of a policy that stays in force, not a claim, so it is its own cells and its own |
|
|
See below |
The forgone deduction is the product#
products/universal_life/technical-notes.md warns against carrying its recursions
across unexamined, and the account-value recursion is exactly where guaranteed UL parts
company with it. On the universal life chassis a monthly deduction the account cannot
carry triggers a grace period and, in short order, a lapse. Here it usually does not:
AV''(t) = AV'(t) − COI(t)
if AV''(t) < 0 and SG''(t) − L(t−1) > 0: # the guarantee is standing
D(t) = −AV''(t); AV''(t) = 0 # the insurer funds the shortfall
else:
grace
D(t) — mth_deduction_forgone_pp — is not a receivable. It never accrues against
future premiums and it is never recovered out of an account-value recovery. The
worked-example anchor demonstrates both halves: month 304 takes $1,965.90 of a
$2,890.47 deduction and forgoes $924.57; months 305 onward forgo the whole $2,900.88;
and when the next annual premium arrives twelve months later the full $8,100 net premium
is credited to an account value of zero, with none of the ~$24,000 of forgone deductions
netted off. A test asserts precisely that.
From month 305 the net amount at risk is the entire discounted death benefit and the
cost of insurance charged on it is forgone. That is the “negative account economics”
regime the notes describe, and it is what dominates late-duration guaranteed UL
liability cash flows. margin_mortality(t) goes deeply negative there, which is the
model saying the same thing.
The zero floor is unconditional here, and in the notes it is not#
The notes’ pitfall list says AV “floors at 0 only while the guarantee is active”, and
their step 6 sets AV''(t) = 0 in the guarantee branch alone — the grace branch gets no
account-value recursion at all. This model floors the account value unconditionally,
in the grace months and after a lapse as well. It is a deliberate [std] deviation, for
two reasons: a negative balance would break check_av_roll_fwd(), which closes on the
deduction actually taken and nothing is taken from an exhausted account; and the notes
give the grace branch nothing to implement.
No cash flow moves. Nothing is deducted either way, a policy in grace surrenders for
nothing by construction, and pols_if(t) is zero once it has lapsed, so every in-force
weighted figure is zero from there on. Two things do follow, and they are worth knowing
before reading a per-policy column:
mth_deduction_forgone_pp(t)keeps reporting the shortfall in the grace months. On the anchor that is $5,007.40 a month att = 81andt = 82, where the guarantee has already failed. It is the guarantee’s running cost only whileis_guar_supported(t)holds; in grace the same number is the notes’ required grace payment, which is whycure_premium_pp(t)is built from it.result_guar()printsmth_deduction_forgonenext tois_guar_activeso the two are read together.The per-policy account value goes on projecting after the lapse. At
t = 85the anchor’s next annual premium credits $8,100 toav_pp, on a row whosestatus(t)isLAPSEDand whosepols_if(t)is zero.
test_the_zero_floor_is_unconditional_and_that_is_a_deviation pins all of it, so the
deviation cannot be forgotten or quietly closed.
The shadow account is never floored#
sg_pp(t) has no zero floor anywhere. Its negative part is the catch-up
requirement: catch_up_prem_pp(t) = max(0, −(SG_t − L_t)) / (1 − 8%). Flooring it at
zero would destroy the catch-up computation and misprice restoration, which the notes
list among the pitfalls. check_sg_roll_fwd() closes the shadow recursion arithmetic
month by month and would fail the moment a floor crept in; model point 4 drives the
balance negative so that the test has something to catch.
The two accounts also discount the death benefit at different rates —
naar_factor() = 1 + j_g on the real account, sg_naar_factor() = 1 + j^g on the
shadow — so their net amounts at risk differ even before their balances do.
The order of tests#
The notes are explicit that the guarantee test must run after the full monthly deduction attempt, because testing before it lets a policy lapse a month early or late at exactly the durations where the net amount at risk is the whole death benefit. The model does that, and it uses two tests rather than one:
is_guar_supported(t)— the step-6 testSG''(t) − L(t−1) > 0, before the interest credits, which decides whether a failed deduction is forgone or opens the grace;is_guar_active(t)— the step-9 in-force testSG(t) − L(t) > 0, after them.
The anchor’s account value fails in month 4 of the projection and its guarantee holds
for another 77 months. The grace period opens in month 81 — the first month with
SG − L ≤ 0 — runs the [std] two-month discretization of the 61-day grace, and the
policy lapses at the beginning of month 83 with no value. pols_lapse_grace(t) carries
those policies out of the in-force roll-forward: it is a contractual termination, not a
rate-based decrement, so it takes the whole remaining block at once and pays nothing.
The COI-scale precision divergence is shipped, not resolved#
The notes state a rule — the current COI scale is 65% and the shadow scale 55% of the
guaranteed maximum — and a worked example computed with m = 5.60 and m^g = 4.74 at
attained age 85. But 65% and 55% of the notes’ own m^max = 8.615 are 5.59975 and
4.73825. The worked example uses the rounded figures, which is how the notes quote
them; the difference is about $0.12 a month of base deduction and $0.65 of shadow
deduction — an order of magnitude more than the cent-level rounding that explains the
rest of that table, and enough to move the golden numbers.
Rather than pick one, the model ships both. coi_rate_dp() reads a model point column
giving the number of decimal places the declared scales are quoted to per $1,000. Point
1 sets it to 2, which reproduces the notes’ table and is a perfectly ordinary way
for an admin system to carry a rate table; points 2–4 leave it blank and take the rule
at full precision. test_coi_scale_precision_divergence_is_shipped_not_resolved pins
both readings and the size of the gap, so it cannot be closed silently in either
direction. Neither value is “right” — the entire scale is a standardization.
What is left of the worked example after that#
With the 2-decimal scales in place the model reproduces every figure of the notes’ five-row table to within 6 cents, and the residual is entirely the notes’ own display rounding. Two sources:
The notes’ arithmetic line uses the net-amount-at-risk discount rounded to the whole dollar —
499,176rather than 499,175.57 on the base account and497,774rather than 497,774.10 on the shadow.test_notes_naar_constants_are_dollar_roundingsnames both.The notes cascade cent-rounded intermediates from row to row. Their shadow interest column runs a cent or so low in every row, and by month 305 the shadow balance has drifted 5.6 cents from a clean recomputation. That is the worst figure in the table; every base-account figure is within about a cent.
The notes anticipate this: “Independent recomputation may differ by cents due to
rounding.” test_worked_example_gap_is_only_the_notes_rounding pins the bound at 6
cents and also asserts the gap is not zero, so neither side can drift.
The anchor’s opening shadow balance is not a funded lifetime guarantee#
The notes’ model point carries sg_init = 118,000 at duration 300 together with a
level P* = 10,800 and a lifetime guarantee. Those do not reconcile: under the notes’
own [std] shadow parametrization a policy funded at P* from issue would be carrying
roughly twice that at duration 300, and no_lapse_premium() on point 1 — the level
premium needed from the projection start given $118,000 — returns about $31,000.
So the anchor’s guarantee fails at attained age 91 and the policy lapses shortly after.
Both figures are labelled illustrative in the notes, and the model ships them as given
rather than adjusting either. test_in_force_anchor_is_not_a_fully_funded_lifetime_guarantee
records the consequence. It is also what makes point 1 a useful cell: it is the only
shipped model point that walks the full cascade — funded, guarantee-supported, catch-up
territory, grace, lapse — inside a projection short enough to read.
Not implemented#
Named here and in the model docstring so their absence is not mistaken for an oversight:
the cumulative-premium-test guarantee design (AG 38 8E Design #2) — the notes document the swap but give no required-premium schedule, so there is nothing to project;
catch-up premium payment —
catch_up_prem_pp()computes the requirement; the notes state expressly that catch-up behaviour is not modelled in the base run;grace cure —
cure_premium_pp()computes the payment; the notes give no cure probability, so a policy in grace always lapses;new policy loans, repayments and withdrawal utilisation — the mechanics are all implemented, but the notes set utilisation to zero and give no pattern;
7702 / 7702A guideline and MEC testing — flagged out of model by the notes; caps and flags, not cash flows, and no guideline inputs are given for this product;
terminal illness acceleration (cash-flow-neutral by the notes), selective-lapse mortality adjustment (excluded expressly) and NGE re-rating;
reserves — VM-20 ULSG, AG 38 and A-830 consume these cash flows and are cited, not reproduced.
Standardizations used#
Everything in this list is std: the entire shadow parameter set (8% load, 5.5%
credited, 55% COI factor, $0.05 per unit, no per-policy charge); the $0.20 base
per-unit charge; the 65% current COI factor and the 3.5% current credited rate; the
illustrative guaranteed-maximum COI curve and the best-estimate mortality table; the
rate-class factors; the base lapse vector and the lifetime multiplier’s flat duration
shape; the premium-pattern and funding-status dynamic factors, the 0.3% floor and the
50% cap; the 1%-grading-to-0% mortality improvement and its 20-year limit; the 15-year
$18 per $1,000 surrender charge; the 5% and 10% return-of-premium exercise rates; the
98% premium persistency; the $300 plus 90%-of-first-year-premium acquisition expense,
the $75 inflating maintenance expense and the $300 claim expense; the two-month
discretization of the 61-day grace; the one-month return-of-premium exercise window;
the even split of a modal premium; the unconditional zero floor on the account value,
where the notes floor it only under a live guarantee; and the coi_rate_dp pin on
model point 1. The
percent-of-premium tax the universal life chassis carries is zero here, because the
guaranteed-UL notes’ expense list has no such item — commission sits inside the
acquisition expense instead.
Tests#
tests/test_guaranteed_ul_us.py asserts all five rows and eleven columns of the notes’
worked example against hard-coded goldens; the notes’ dollar-rounded NAAR constants and
the exact bound on the residual gap; the COI-scale precision divergence in both
directions; one test per entry in the notes’ “Known modeling pitfalls” list — the NAAR
discount convention and its zero floor, the simple-twelfth COI conversion against the
compound experience conversion, the account-value floor and the fact that this model
applies it unconditionally where the notes do not, forgone deductions not being
receivables, the shadow account never being floored, the order of the guarantee tests,
and the ANB basis; the corridor table against the IRC 7702(d)(2) applicable percentages
at every statutory breakpoint; the eleven-year guarantee-failure shift a 98% premium
persistency produces; the in-force, account-value, shadow-account and margin roll-forwards
on all four model points; the return-of-premium windows and the 40%-of-face cap; the
three lapse multipliers; the surrender-charge run-off; the mortality-improvement grading
and its 20-year cap; the funding-premium solve against the notes’ calibration; and that
every model point in the table projects.
It also pins the library-wide conventions this model shares: that the result_cf()
columns sum to net_cf income-positive, that pols_if(t) is the start-of-month count
weighting its own row, that a withdrawal is withdrawals(t) and claims(t, "WITHDRAWAL") raises so the claims total cannot double-count it, and that every
check_* is a no-argument bool.
python -m pytest tests/test_guaranteed_ul_us.py -q
Verifying this copy#
tests/test_guaranteed_ul_us.py asserts this model against the worked example in
technical-notes.md, and it ships inside this library — so it runs
against the copy you are holding, including any changes you have made to it:
python -m pytest tests/test_guaranteed_ul_us.py -q
The whole suite, all twelve models and the shared conventions, is python -m pytest tests -q.
If you change an assumption and a test goes red, the worked example in the notes and the
model have parted company — which is the question this library exists to let you ask.