Implementation Notes#
Status: Draft, 2026-08-20. Built from
products/income_guarantee/technical-notes.md; the product it
implements is specified in product-spec.md. The protection chassis
it states deltas against is 定期保険, specified in
products/term_life/product-spec.md with its
technical notes and implemented in
Term_JP_A — this model carries that model’s names for every
shared concept, and restates none of its machinery. S# and R# ids resolve
against sources.md, and [REG-R#] against
references/regulatory-and-actuarial-references.md.
This is a mechanics demonstration, not a pricing or reserving result. What is sourced on this product is the shape: the instalment count
max(N − m + 1, G), the 最低支払保証期間 (saitei shiharai hoshō kikan, minimum payment guarantee period) as an extension of the payment period past the expiry date rather than a floor inside it, the absence of any survival condition on the instalments, the absence of any surrender value (kaiyaku-henreikin, 解約返戻金) at any duration, premium cessation on the annuity event, the absence of renewal (kōshin, 更新), and the anchor cell’s ¥2,565 monthly office premium, which is a published rate [S6]. Everything else quantitative is std: the mortality table shipped here is a construction and not a copy of the standard mortality table for death cover (seiho hyōjun seimeihyō 2018, 生保標準生命表2018), and the best-estimate factor, the four rate-class factors, the lapse table, every expense and commission level, the commutation discount rate and all four optional-module parameters are standardizations introduced for the reference implementation. Replace them with company data before drawing any conclusion from the output.
IncomeTerm_JP_S is the executable counterpart of technical-notes.md. It projects gross
best-estimate liability cash flows on a monthly grid for a single-policy model point of
survivor income term (shūnyū hoshō hoken, 収入保障保険) — a death benefit paid as a level
monthly income to a fixed expiry date, floored by the 最低支払保証期間. It is not income
protection: nothing here models a disability decrement, and a reader arriving from
uklib’s IP_UK_S should expect the family income benefit shape instead.
Run it#
From the repository root:
python products/income_guarantee/run.py # the anchor cell, point_id = 1
python products/income_guarantee/run.py 6 # another model point
run.py prints the model point, the first twelve policy months, the last month of cover and
the run-off tail after it, the undiscounted totals, and every check_* cells. Its output is
ASCII-only, so it prints on a Windows console under any code page: amounts are written “JPY”
and the product name is romanized. In a session:
import modelx as mx
model = mx.read_model("products/income_guarantee/IncomeTerm_JP_S")
model.Projection[1].result_cf() # the worked example's anchor cell
model.Projection[1].result_pols() # the populations and decrement rates beside it
Projection takes a point_id and Projection[1] is the worked example’s anchor cell.
result_cf() returns a DataFrame indexed by policy month t, one column per cash flow
line, with pols_if first and net_cf last. 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.
The projection horizon is longer than the policy term#
This is the one structural fact the model exists to get right, and it drives the shape of
almost every cells. Where the insured event falls so late that fewer than G months remain,
the annuity payment period is extended past the expiry date until the guarantee has run
[S1 第3条第2項] [S3 第3条第3項] [S5] [S12] [S14]. So
proj_len() = term_m() + guar_m() - 1
On the anchor cell that is 443 months against a 420-month term: a claim in policy month 420
pays its twenty-fourth instalment twenty-three months after cover ended. pols_if(t) is
zero for every t beyond term_m(), and in those months the only surviving lines are the
annuity instalments and their administration expense — check_expired_cover() asserts
exactly that, month by month. Terminating at t = N drops ¥2,645.21 of contractual claim
outgo on the anchor cell, 0.5967% of the total, and nothing else looks wrong afterwards,
which is what makes it the easiest error to make on this product and the least visible.
The guarantee is a term extension, not a benefit floor. Both readings pay the same
max(N − m + 1, G) instalments, so an undiscounted total cannot distinguish them; they
differ in when. pay_count(m) and pay_end(m) express the same contractual rule from
opposite ends — how many instalments a claim makes, and the month the last of them falls —
and check_pay_count() asserts pay_count(m) == pay_end(m) − m + 1 for every month of
cover. pay_end(m) = max(N, m + G − 1) is the whole guarantee mechanic in one expression:
for m <= N − G + 1 every stream ends at exactly N whenever it opened, because the expiry
date is fixed at issue.
The in-payment ledger#
annuities_if(t) is the notes’ R(t): the number of annuity instalments falling due in
month t, per policy issued. It is not a population of policies, which is why it is not
spelled pols_*, and it must never be summed with pols_if(t). On the anchor cell
annuities_if(420) = 0.016780 while pols_if(420) = 0.145023, and adding them produces a
number with no meaning.
The recursion is
annuities_if(t) = annuities_if(t-1) - annuities_ended(t) + annuities_open(t)
with new claims entering in the same month, because the first instalment falls at the
end of the month of the insured event [S1 第3条第2項] [S5]. The ledger is never decremented
— not by the insured’s mortality, which is what opened the stream, not by the recipient’s,
and not by lapse, since a policy in claim cannot lapse and its premiums have ceased. On the
composite the instalments carry no survival condition [S1] [S5] [S14], confirmed from the
contract side by the one published factor table, which depends on the payment period and
explicitly not on the annuitant’s age or sex [S13 別表1]. Applying the surviving-policy
factor (1 − q_m)(1 − w_m) to the ledger — the natural thing to do if R is mistaken for a
population of policies — cuts annuity outgo on the anchor cell from ¥443,313.69 to
¥245,605.66, an understatement of 44.6%. It is the largest single error available in this
model.
Two identities guard it, both implemented as check_* cells with a per-t signed residual:
check_annuity_ledger()rebuildsannuities_if(t)directly from the claim vector — the streamsswiths <= t <= pay_end(s)— with no reference to the recursion.check_annuity_total()checks the running totalannuities_cum(t)against an independent count of the instalments each claim contributes at or beforet. Att = proj_len()this is the notes’ identitysum of R(t) = sum of D(s) × n_pay(s): 2.955425 instalments against 0.016780 expected claims, an average of 176 instalments a claim, which is the number that says this is an income product and not a sum-assured one.
A consequence worth stating separately: the ledger peaks at exactly month N, where it
equals the sum of every claim the contract has ever made. An implementation that ends
streams one month early gets that identity wrong by one month’s claims and nothing else
visibly changes.
Two populations, and which cash flow line carries which#
pols_if(t) and annuities_if(t) are disjoint, and every line of result_cf() belongs to
exactly one of them:
Carried on |
Carried on |
|---|---|
|
|
Two things follow that an implementation can easily get wrong. Premiums stop on the
annuity event; the benefit does not [S1 第12条第2項] [S3 第5条] [S8] — netting the two against
one combined population would collect ¥7,535.43 of premium on the anchor cell, 1.63% of the
total, from policies in claim and paying nothing. No explicit term is needed for the
cessation, because a policy in claim has already left pols_if; what the model must not do
is give the ledger a premium. And annuity_expenses rides the ledger, at ¥200 per
instalment std, not the in-force population: it is the one expense that survives the
end of the term, so attaching every expense to pols_if charges nothing at all in months
421 to 443, when instalments are still being paid. It has no analogue on the protection
chassis, which pays a lump sum and closes the file.
The nine model points#
# |
Cell |
What it exercises |
|---|---|---|
1 |
M30, 65歳満了, 保証2年, ¥150,000, 非喫煙者優良体, ¥2,565 |
Anchor. The worked example, all modules off |
2 |
F30, same otherwise, ¥2,175 |
The female mortality construction |
3 |
M45, 65歳満了, ¥150,000, ¥2,865, 年払 |
A 20-year term; the annual frequency |
4 |
M30, 60歳満了, ¥100,000, ¥1,410 |
一括受取 on: every claim commuted |
5 |
F40, 60歳満了, ¥100,000, ¥1,400 |
リビング・ニーズ on, at a stream the cap does not bind |
6 |
M35, 65歳満了, 保証5年, ¥150,000, 喫煙者標準体, 半年払 |
The 5年 guarantee, the worst rate class, the semi-annual frequency |
7 |
M50, 60歳満了, ¥200,000, 非喫煙者標準体 |
保険料払込免除 on |
8 |
M70, 75歳満了, 保証5年 on a 5-year term, ¥50,000, 喫煙者標準体 |
復活 on, and the edge case |
9 |
F35, 65歳満了, ¥100,000, 喫煙者優良体 |
The fourth rate class |
Every point projects without NaN and every check_* cells returns True on all nine.
Inputs are external files#
The model folder holds __init__.py, _system.json and the two Space folders and nothing
else — no _data/, no embedded values. The inputs are plain CSVs in the product directory
beside run.py, read at run time by the Data Space, which is unparameterized so each file
is read once per model however many policies are projected. Data.input_dir() resolves
to _model.path.parent, so the model works from any checkout location. This follows
annuallife/TradLife_A; basiclife/BasicTerm_S stores its inputs inside the model instead.
The trade-off is stated rather than hidden: copy the model folder without the CSVs and it
reads, then fails on first evaluation. In exchange a diff 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.
File |
Contents |
Provenance |
|---|---|---|
|
Nine model points indexed by |
|
|
死亡保険用 table rates by sex and attained age 20-89, the range this model can read |
The library’s one canonical std construction, anchored on the individual rates read from the IAJ table R1 REG-R18; per-row |
|
Annual ordinary lapse rate by policy year, five rows, the last applying to year 5 and beyond |
std chassis table, reconciled to the LIAJ FY2024 個人保険 解約・失効率 of 5.6% REG-R31; nothing product-specific exists |
|
|
std; no carrier publishes a class differential for this product [S2] [S5] [S6] [S14] [S16] |
The mortality table is a construction, not a copy. 生保標準生命表2018(死亡保険用)is published at
a stable public URL and anyone may go and read it R1 REG-R18, but its publisher
prohibits reproduction and transmission without written consent REG-R21. The library
therefore
cites the table, quotes the individual rates the worked example needs, and ships a
table whose anchor rows carry those quoted rates and whose remaining rows are log-linear
interpolations in ln q between the two neighbouring anchors, rounded to five decimals. The
anchor set is the union of every anchor any jplib product reads — both sexes at ages
20, 22, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80 and 85, plus male 31–34 — so the same
cell carries the same value and the same provenance in every product that ships it, and
the file is identical across the library up to the age range each model reads. Female rates
are read at the female anchors in their own right, not built as a ratio to the male rate.
No age is extrapolated: every shipped age lies strictly between two sourced anchors. The
anchors the worked example touches are male q30 = 0.00068, q31 = 0.00069,
q32 = 0.00070 and q45 = 0.00177; q44 = 0.00163, q63 = 0.00851 and q64 = 0.00929
are interpolated. Keep the second distinction separate from the first: even the real table
is a
valuation table carrying a ~2σ margin capped at 130% of the unadjusted rate R2
REG-R20, so a best-estimate basis is a std adjustment of it either way. Here that
adjustment is mort_be_factor = 0.80 times the rate-class factor.
Where the premiums come from. Five model points carry a published monthly rate [S6]: points 1, 2 and 3 from the 65歳満了 / 保証2年 / 非喫煙優良体型 / 年金月額¥150,000 grid (¥2,565 at M30, ¥2,175 at F30, ¥2,865 at M45), points 4 and 5 from the 60歳満了 / 年金月額¥100,000 grid (¥1,410 at M30, ¥1,400 at F40). Three are std scalings of a published cell, and each says which dimension it scales and which it leaves unpriced:
point 6, ¥2,565 (the published M35 cell of the 65歳満了 / 保証2年 / 非喫煙優良体型 / ¥150,000 grid) × 1.35/0.70 for the rate class, rounded to ¥4,950. The move from 保証2年 to 保証5年 is not priced — no carrier publishes a guarantee-length differential;
point 7, ¥1,330 (the published M45 cell of the 60歳満了 / ¥100,000 grid) × 2 for the ¥200,000 monthly amount × 0.90/0.70 for the rate class = ¥3,420. The move from age 45 to age 50 is not priced, which that grid makes defensible: it is nearly flat in issue age (¥1,410 at 30 against ¥1,330 at 45) because the shortening term offsets rising mortality;
point 9, ¥2,475 (the published F35 cell of the 65歳満了 / ¥150,000 grid) × 100/150 for the ¥100,000 monthly amount = ¥1,650, × 1.05/0.70 for the rate class = ¥2,475.
Point 8 sits where no published grid reaches, and its ¥7,000 is std, at roughly the
undiscounted break-even on the model’s own basis (−¥19.57 net over its 60-month term).
This file is the only place that distinction is recorded, because a model point table is
not an assumption table and carries no provenance column.
One deliberate departure from the notes’ layout. technical-notes.md lists
class_factor among the model point attributes; it is held in rate_class_table.csv
instead, keyed by rate_class. The four factors are one std structure rather than
four free numbers — a smoker/non-smoker ratio of 1.50, a preferred/standard ratio of 0.778,
levels pinned by the requirement that the mix-weighted mean be 1.000, because 生保標準生命表2018 is
an all-lives basis — and a per-policy copy of a shared assumption is a place for the points
to drift apart. check_class_factor_norm() asserts that normalization, which a model point
column could not. The values are identical either way.
Modules that are off in the base run#
Five of the notes’ optional constructions are implemented and switched off, so that the base
run reproduces the worked example while the machinery stays visible and testable. Four are
model point columns; the fifth is a Projection Reference.
Module |
Switch |
Off value |
What it does when on |
|---|---|---|---|
Full commutation (一括受取) |
model point |
|
Settles the claim at the claim date with |
リビング・ニーズ特約 |
model point |
|
Settles a std proportion |
保険料払込免除 |
model point |
|
Runs a two-state incidence/recovery chain std on the premium-paying population, on the 不慮の事故 plus 180 days plus 別表4 test [S1 第6条] [S3 第8条] [S5] [S6]. 別表4 is a materially lower bar than the 別表3 高度障害 schedule, so the incidence deliberately does not reuse |
Reinstatement (復活) |
model point |
|
Returns a std proportion |
Selective lapsation |
Reference |
|
Loads persisters’ mortality by |
The リビング・ニーズ module has one product-specific consequence. Because the payout is the present
value of an income stream rather than a level sum assured, the cap binds from month 1 on
the anchor cell’s parameters, where the full 年金現価 at issue is ¥56,352,381.90, and stops
binding only in month 209. That is the opposite pattern to a level sum assured, where a cap
either always binds or never does. The <= 6-month timing shift the acceleration really
produces is ignored on this grid std.
Two of the notes’ constructions are not implemented, and are named so the absence is not
read as an oversight. The recipient-mortality variant, which one carrier alone writes
[S3 第3条第3項], would need a post-event mortality basis on a life the contract never
underwrote, and the 年金開始後用 table stays on the 2007 vintage REG-R11; it is out of scope
std. And there is no 更新 on this chassis [S5], so the renewal repricing and decline
decrement of the protection chassis are deliberately absent and the term is 歳満了 only,
derived from expiry_age rather than read as a policy term.
Sign convention#
The notes’ CF(t) is already income-positive — they write + = inflow — which is the
library-wide sign of net_cf, so there is no outgo-positive liability_cf companion to
publish: one stream, one sign, one name.
claims_annuity is a death benefit here, and the column that is absent is a product
fact. The instalments are paid on the death of the insured, or on the contractual 高度障害
state carried inside the same decrement as its accelerated equivalent — not on survival and
not on disability as such. The name records the benefit’s form, and the same name carries a
living benefit in LTC_JP_S (nursing care model notes (介護保険)) and
Annuity_JP_A (individual annuity model notes (個人年金保険)), so this model states
the contingency in the claims docstring and in the result_cf docstring rather than leaving
it to the column name. There is no claims_death column: the contract pays no lump sum on
death at any duration — a claim opens an annuity stream instead of settling one — so the
whole death benefit is claims_annuity, and a zero-valued claims_death would misdescribe
the contract rather than document it. Nor is there a bare claims column beside the four
claims_* splits: a statement that prints its own subtotal next to its parts stops adding to
net_cf unless the reader knows which column to skip. check_net_cf() re-adds the published
columns to net_cf(t) in every one of the 443 months and is the assertion that keeps both
properties true.
claims_lapse is identically zero because the
composite is 無解約返戻金型 with no 解約返戻金 at any duration [S2] [S5] [S6] [S7] [S9] [S15]. The zero
column is published rather than dropped, because a non-zero lapse row imported from a
cash-value chassis is one of the notes’ listed pitfalls and a missing column would only hide
the product fact. For the same reason there is no cv_pp and no automatic premium loan
(jidō furikae kashitsuke, 自動振替貸付) anywhere in the model: with no cash value there is no
collateral to lend against, and one carrier states the absence in terms [S2].
One presentational note. The notes’ worked-example table puts E0 + c0 in a “Maint. +
acq.” column and reserves “Comm.” for the renewal commission; the model follows the notes’
own Notation table, where E0 is expense_acq and both c0 and c_r are commissions.
Only the column allocation differs — the notes’ totals table separates “Commission (renewal)
¥21,577.36” from “Acquisition (E0 + c0) ¥30,390.00” and the model reproduces both to the
yen — and the test module maps between the two explicitly.
Naming#
Cells names follow lifelib’s basiclife/BasicTerm_S wherever that model has an analogue:
pols_* for policy counts, plural nouns for cash flows, *_rate for rates, *_mth for the
monthly form of an annual rate, *_pp for per-policy amounts, claims(t, kind) with an
uppercase kind string, pols_if_at(t, timing) for the within-month in-force reads. The
notes use compact actuarial symbols; the full mapping lives in the Projection docstring.
Five names are fixed library-wide rather than chosen here, and this product carries the settled spelling of each:
Library-wide name |
What it means |
Why not the alternative |
|---|---|---|
|
The level monthly office premium per policy |
|
|
The number of months in one premium payment period: 1, 6 or 12 |
|
|
The table rate in period |
|
|
The per-period in-force roll-forward check, with |
The settled name in both sister libraries, so one test can call the same check across every model |
|
The check that the published |
The settled |
Five further cases needed care in this product:
Notes |
Cells |
Why |
|---|---|---|
|
|
A count of instalments falling due, not a population of policies, so deliberately not spelled |
|
|
The unsuffixed name is the annual rate, library-wide. A monthly-grid model using |
|
|
The same contractual rule from opposite ends. Keeping both, rather than deriving one silently, is what lets |
(no symbol) |
|
The survivors of the last month of cover, who neither die nor lapse — their cover runs out. Bookkeeping, not a benefit: nothing is payable on survival. Without it the roll-forward appears to lose lives with no cause |
|
|
Out of |
Standardizations used#
Every one of these carries its rationale in the cells docstring that uses it:
mort_be_factor = 0.80 (the chassis best-estimate factor) and the whole shipped mortality
table; the four class_factor values and their illustrative mix; the lapse table;
expense_acq ¥15,000, expense_maint ¥4,000 p.a., expense_claim ¥30,000,
expense_annuity ¥200 per instalment and inflation_rate 1.0%; comm_init_rate 50% and
comm_renewal_rate 5%; commute_rate 0.65% p.a. effective; death-before-lapse as the
processing order; and the incidence, take-up, lag and interest parameters of the optional
modules — ln_take_up, wop_inc_rate, wop_rec_rate, reinst_rate, reinst_lag_m,
reinst_int_rate and sel_lapse_ref — none of which any retrieved document quantifies.
Those seven carry their numeric values and their rationale in the Optional modules table
of technical-notes.md, which is where a reader who wants to replace
one should look; the model’s References hold the same numbers and nothing else.
Two deserve their range stated. commute_rate is the best-evidenced std number here:
one 特約条項 publishes a factor table with no dependence on the annuitant’s age or sex [S13
別表1], fitting an annual annuity-due less a small constant at about 0.61% p.a., and three
carriers publish a worked commutation amount whose ratios cluster at 0.92190, 0.92183 and
0.92133 [S6] [S10] [S17], the last two implying 0.66%. The midpoint of those two anchors is
0.635%, and 0.65% is that rounded to the nearest 0.05%; the range to test over is
0.61%-1.45%.
class_factor is the worst-evidenced and the largest lever in the file: annuity outgo
on the anchor cell runs ¥443,313.69 at 0.70 and ¥846,803.38 at 1.35, a factor of 1.9 across
the composite’s own four classes, on a premium quoted for the cheapest of them. It
multiplies with mort_be_factor, whose own range runs ¥426,306.73 to ¥552,708.11.
expenses(t) is acquisition plus maintenance only. The claim expense is its own cells,
claim_expenses(t), deducted explicitly in net_cf(t) and published as its own
claim_expenses column; the annuity administration expense is a third, annuity_expenses(t),
because it rides the ledger rather than the in-force population. The notes’ worked-example
table prints the last two combined in a single “Claim + ann. exp” column — that is a
presentational grouping in the notes, and the model publishes the three separately.
The 年金現価 cap of ¥300,000,000 [S8] is a contractual ceiling on the present value of the instalments. It is not implemented, and it binds on none of the nine shipped points — the largest present value in the set is the anchor cell’s ¥56,352,381.90, under a fifth of it.
Discounting is out of scope for the library, so the notes’ four present-value diagnostics
are not cells either. They were reproduced against this model, and reproducing them takes
the notes’ own split timing: the start-of-month leg — premiums, maintenance, renewal
commission — discounted at (t − 1) / 12, and the end-of-month leg — the instalments, the
claim expense and the annuity administration expense — at t / 12. That gives −¥73,865.82
at 0.5%, −¥49,216.50 at 1% and −¥10,895.85 at 2%, which is the notes’ table to the yen. A
reader who instead discounts the whole of net_cf(t) at t / 12 gets −¥73,998.78,
−¥49,465.63 and −¥11,336.00. The gap is small and is entirely a timing artefact, but on a
product whose claim leg discounts far harder than its premium leg it is worth knowing which
convention produced a printed number.
Tests#
tests/test_model_conventions_jp.py asserts the house style over every registered model —
the folder layout, the Data/Projection split, the read-once property, the docstring
contract, the lower_snake_case naming, the retired-name register, the check_* protocol,
the result_cf column conventions, that every model point projects without NaN, and that
read → write → re-read reproduces the same file set and the same numbers.
tests/test_income_guarantee_jp.py asserts this product’s own facts. The notes’ seven-row
worked example is hard-coded as a module-level table, to the precision the notes display —
money to the second decimal, in-force to six, the claim and ledger populations to nine and
the decrement rates to twelve, because q_m at attained age 30 is about 3.17e-05 and six
decimals would leave it with two significant figures — 0.000032 against 0.000031738873, a
0.8% distortion carried into every claim of the first policy year. Beside it sit the
decrement vector at the three months where something moves, the month-1 and month-13 traces
line by line, the four sourced anchors and the three interpolated table rates the notes
quote, the totals table, the structural
quantities behind it, and the four present-value diagnostics with the split timing that
reproduces them.
Then one test named for each of the thirteen entries in the notes’ Known modeling
pitfalls list. Three of them build the wrong answer explicitly, so that the test fails
loudly rather than merely differing: the decremented ledger at ¥245,605.66, the ¥7,535.43 a
netted population would collect, and the ¥2,645.21 tail a truncated horizon would drop.
Then the seven check_* cells and the in-force roll-forward on all nine model points; each of
the five optional modules in both positions, the switched-on ones on the model point that
carries them; and the structural facts — the annuity-certain benefit, the G = N edge cell,
premium frequency as a timing feature, the lapse sensitivity whose sign is the opposite of
the chassis’s, the mortality lever’s range, and the model point envelope enforced by name.
python -m pytest tests -q