Implementation Notes#
Status: Draft, 2026-08-26. Built from
products/obseques/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 twelve-month délai de carence and its two benefits, the refund of premiums collected rather than accrued [S1] [S8] [S9], the 1.00 % guaranteed revalorisation of the capital and the first-anniversary eligibility for it [S14] [S1] [S9], the surrender value equal to the provision mathématique [S1] [S8] [S9] [S12], réduction to a paid-up capital on non-payment R7, the surrender-value and prime unique scales [S5] [S14] [S15] [S2], and the premium of every model point. Every rate is a std standardization. TH 00-02 and TF 00-02 are the homologated regulatory tables for this product REG-R22 REG-R23; they are cited by name and never redistributed, so the mortality shipped here is an INSEE-derived proxy REG-R24 anchored so that the anchor cell’s best-estimate factor is the notes’ own placeholder rate exactly. No public French source gives any lapse, surrender or paid-up rate for this product, so every behavioural assumption is a drafting construction.
Run it#
python products/obseques/run.py # the RefOBS-VIA anchor cell
python products/obseques/run.py 3 # the prime unique cell
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/obseques/Obseques_FR_S")
model.Projection[1].result_cf()
Three cells, one engine#
The three cells are the same contract with one model point column changed —
premium_form, this product’s signature. Nothing branches on cell(), which is a label
rather than a switch.
RefOBS-VIA |
RefOBS-TMP |
RefOBS-UNI |
|
|---|---|---|---|
|
|
|
|
Premium, entry 50, 5000 € |
336.03 €/year for life [S14] |
651.26 €/year for 10 years [S14] |
4274.04 € once [S5] |
Revalorisation |
1.00 % p.a. guaranteed [S14] |
1.00 % p.a. [S14] |
0.00 % std |
Premium-stop decrement |
to the end |
to the end of the term |
none |
Crossover |
|
|
never ( |
Anchor |
model point 1 |
model point 2 |
model point 3 |
The prime unique cell takes a zero revalorisation rate because its rate card presents its values sans participation aux bénéfices [S5], so a non-zero rate would be inconsistent with that document’s own surrender scale. The same reasoning gives zero to the three other cells priced off tables published on that basis [S5] [S15] [S2].
Two structural features separate this product from
WOL_UK_S, the UK guaranteed-acceptance
over-50s cell that is otherwise almost the same contract: the capital is a state
variable, and lapse pays money. Both are first-order, and both are asserted.
The time index#
t is the 0-based policy month, the library-wide convention. t = 0 is the first
projected month, month t runs from time t to time t + 1, and the frame is
t = 0, 1, ..., proj_len() - 1 len(result_cf()) == proj_len()
so proj_len() is the number of projected months — 12 × (omega_age − entry_age + 1),
756 on the anchor cell and 516 on the entry-70 one — and the last projected month is
proj_len() - 1. Every model point here is new business, so every frame starts at 0.
The policy year is the contractual, 1-based label derived from it,
policy_year(t) = duration(t) + 1 = t // 12 + 1, and it is what the premium, lapse and
select schedules are keyed by. duration(t) = t // 12 is 0-based, as in lifelib, and
duration_mth(t) = t is the months elapsed at the start of month t; t + 1 months
have elapsed by the end of it, which is where deaths, surrenders and réductions fall.
Three consequences worth naming, because each is a place an off-by-one hides:
the twelve carence months are
t = 0 … 11, soin_carence(t)ist < carence_monthsand the signature discontinuity falls betweent = 11andt = 12;the counts line up with the notes with no offset:
pols_if(t)is the notes’l(t), the in-force at timetmonths from issue, andpols_if(0) == pols_if_init();crossover_mthreturns a month on this frame, so0is a real answer and the “never crosses” sentinel is-1.
The délai de carence is two benefits, not one#
For twelve months [S1] [S8] [S9] [S11] [S12] [S13]:
a non-accidental death refunds the premiums collected —
refund_pp(t), which under an annual premium in advance is a step function, constant at 336.03 € through monthst= 0 to 11, not a monthly accrual;an accidental death pays the full guaranteed capital from day one [S1] [S8] [S9] [S11] [S13].
From t = 12 any death pays the capital. Expected death outgo steps by a factor of
7.8080 between t = 11 and t = 12, and the step decomposes exactly into three
independent moves — in-force 0.994191, monthly mortality 0.885251, benefit 8.871657.
That discontinuity is the reason the grid must be monthly.
Three ways to get it wrong, all of them large, all of them tested:
Error |
First month ( |
Policy year 1 |
|---|---|---|
Correct |
0.380884 |
4.4274 |
Pay the capital inside the carence |
3.345618 (×8.78) |
38.8893 |
Drop the accident leg |
0.224846 (−41 %) |
2.6136 |
Accrue the refund base monthly |
— |
3.2750 (−26 %) |
benefit_pp(t, "ILL") and benefit_pp(t, "ACC") carry the two legs and
benefit_pp(t, "DEATH") blends them by acc_share, 5 % std. That share is set
below any plausible external-cause share on purpose: the contractual definition of an
accident is narrower than external-cause mortality, because cerebral and
cardio-vascular events are never accidents whatever their origin, and the burden of proof
is on the claimant [S1] — with the market description adding myocardial infarction,
coronary conditions and emotional shock R21, and the other contract that defines an
accident narrowing it a different way, by excluding acute and chronic illness and harm
from medical or surgical treatment [S8]. It matters only inside the waiting period — where
it is the whole of the difference between a refund and a capital.
The accidental multiplier doubles the accidental benefit, capped at 20000 € [S8], and
applies past the waiting period only. Inside it the accidental benefit is already the
full capital, so doubling it there — or applying the multiplier to all deaths —
overstates outgo. accident_mult() is used in exactly one place,
benefit_pp(t, "ACC"), past the carence only.
The capital is a state variable#
capital_pp(t) is C_0 × (1 + r)^(y−1), uprated out of the participation aux bénéfices
[S1] [S2] [S15] [S16] REG-R14 REG-R15. The uprating starts at the first
anniversary, not at issue, because PB is allocated to contracts in force at least a year
[S1] [S9] — so capital_pp(t) == capital_0() for t < 12, which check_capital_reval()
asserts. Uprating at issue would make it 5050.00 € in the first year and overstate the
year-1 accidental leg.
Two parameters are easy to confuse and are kept apart deliberately. reval_rate moves the
capital. carence_refund_rate credits interest on a refund of premiums, is zero
in every retrieved contract [S1] [S8] [S9], and reaches a different product in statute —
the legal-rate floor of the loi Sueur is drafted for the advance-prestations form R6.
The illness benefit inside the waiting period is a refund, not a capital, and carries no
revalorisation.
reval_prem_linked is the fork that must never be hard-coded: five of the seven retrieved
insurers leave the premium alone [S5] [S6] [S7] [S14] [S16], one raises the remaining
premiums in the same proportion [S9] [S10] [S11]. reval_simple is the second reading of
“1 % du capital souscrit” — a simple uplift on the subscribed capital rather than
compounding on the current one. Which reading the wording intends is unverified; compound
is the reference std and the simple form is model point 11.
Lapse pays money, and réduction is not termination#
Rachat pays the provision mathématique [S1] [S8] [S9] [S12], because a whole-life contract
sits in art. L. 132-23 CA’s residual autres assurances sur la vie class, where the insurer may
refuse neither rachat nor réduction. The article withholds them from a closed list only:
temporary death assurance and immediate or in-payment annuities may carry neither, and
survivorship capitals, pure endowments and deferred annuities without return of premium may
carry no rachat R10. So
claims(t, "LAPSE") is non-zero from t = 0 and worth 1005.89 € over the anchor
cell’s horizon. This is where the UK sibling’s model is actively misleading: there a lapse
pays nothing, every lapse extinguishes a liability for free, and raising lapse always
lowers the liability. Here it does not.
Anchor cell, undiscounted, before expenses |
Net stream |
|---|---|
As modelled |
+2236.92 |
|
+3242.81 (+45 %) |
Lapse decrement removed altogether |
+3165.11 |
The two right-hand rows move in the same direction for opposite reasons, and the second is the one that matters: the premiums a lapser stops paying are worth more than the reserve handed back, so zero lapse raises the liability. Both are asserted.
No premium-stop decrement where no premium is due. After prem_cease_age, past the
end of a temporary term, on a prime unique cell and in the paid-up state there is
nothing left to stop paying, and a decrement there silently destroys liability.
lapse_rate() is zero in all four, and check_lapse_gate() re-states the gate through
prem_due_pp rather than through in_paying_period, so it does not merely repeat the
branch it is checking.
Réduction is a paid-up contract, not an exit. Non-payment produces réduction
wherever the surrender value is sufficient R7 [S1] [S8] [S9], so a share
reduction_share of premium-stops converts — a state change with no cash flow — and the
contract still owes those policies a reduced capital for the rest of the insured’s life.
It is carried as a second population strand:
Cells |
What it holds |
|---|---|
|
policies still paying premiums, measured at the start of month t — the notes’ |
|
paid-up (réduit) policies at the start of month t — the notes’ |
|
the aggregate paid-up capital in force |
|
the sum, which is what the maintenance expense is carried on |
Carrying the aggregate capital alongside the count removes the need for a per-conversion
cohort dimension: reduced_capital_pp(t) depends on when the policy converted, but every
paid-up policy thereafter rolls forward on the same survival factor, so the sum of their
capitals satisfies the same recursion as the count. Death outgo on the strand is then
capital_paid_up(t) × q_m(t).
reduction_share is 0 in the base cell, 0.5 on model point 5 and 1.0 as the upper stress
std. No public source gives any split between voluntary surrender and paid-up
conversion, because none gives any decrement rate at all. It dominates the late-duration
liability and must never be approximated by perturbing the lapse rate instead.
The overrun, and its two crossovers#
Under primes viagères cumulative premiums grow without bound while the capital grows at
most at the revalorisation rate, so the insured can and often does pay more than the
capital — the KID says so in terms [S11]. crossover_mth(basis) finds the month, and it
finds two:
Basis |
Anchor cell |
Notes |
|---|---|---|
|
|
against |
|
|
against the revalorised capital, 6048.54 € against 5921.52 € |
Three years apart, and publishing one without saying which is how a stated crossover moves
by years. The standardised tables add a second convention on top: they date their columns
by the age at the end of the year, so their “age 65” column is this model’s attained
age 64 during policy year 15. Model point 7 reproduces the notes’ subsidiary table exactly
on that reading — 2467.80 / 4113.00 / 5758.20 / 7403.40 € of cumulative premiums at the
tables’ ages 65 / 75 / 85 / 95 [S5] — and crosses at t = 360, policy year 31, attained
age 80.
Letting lifetime premiums stop by accident removes the overrun and with it the product’s
characteristic feature. prem_cease_age is 0 on the anchor std, so it is still
collecting 336.03 € a year at attained age 100; model point 10 is the documented
“jusqu’à vos 80 ans” form [S9] [S10] and stops at 30 premiums.
The overrun-aware lapse module — lapse_rate × (1 + beta) past the tipping point — is a
pure stress dial, and lapse_overrun_beta is 0 in the base run.
The surrender value is an input, not a formula#
surr_value_pp(t) reads a published scale rather than computing a prospective
provision mathématique, and that is a deliberate limitation. The contract makes the
surrender value the mathematical provision [S1] [S8] [S9] [S12]; a production model
computes it prospectively on the tariff basis; and no French insurer publishes its
tariff basis — the whole retrieved set contains one technical rate with a table (0.75 %
with TH 00-02 [S8]) and one rate alone (0 % [S1]).
What every insurer does publish, since 1 July 2025, is a standardised table of surrender
values by duration for a 5000 € capital R13 R15. The model reads that, interpolates it
linearly in elapsed months from issue between the published quinquennial anchors
std and holds
it flat beyond the last one. Because a rachat resolves at the end of month t, the
lookup key is duration_mth(t) + 1 = t + 1: surr_value_pp(0) is the one-month value,
13.07 € on the anchor cell, and the published five-year anchor of 784.01 € is
surr_scale_pp(59). The anchors already embed that insurer’s own revalorisation,
which is why surr_value_pp is not additionally scaled by capital_pp — it is
pro-rated to the policy’s own capital_0 and netted of any penalty, and nothing else.
Every published anchor is shipped, not a sample of them. Each grid carries all nine quinquennial values, at 60 to 540 months. Dropping the intermediate ones and letting the interpolation stand in for them is not a rounding: on the Mutex temporaire 25 ans grid it puts month 300 at about 4497 € against the published 5074 €, 11 % low, and erases the peak the grid exists to show — a temporary-premium surrender value that tops out at the end of the premium term and then declines under the 0.40 % p.a. charge on the capital [S1] [S2]. A test asserts all nine anchors on all six grids.
The other consequence is that the premium, the revalorisation rate and the surrender scale
of a model point must come from one document. Feeding one insurer’s premium into another’s
grid produces plausible-looking and wrong margins: the lifetime premium for the same capital
spans 2.0:1 across the retrieved set at entry age 50, 1.7:1 at 60 and 1.5:1 at 70. A test
asserts that every surr_scale value names a scale that exists.
single_prem_rate(x) is the second scale and serves twice: it is the tariff behind the
prime unique form, and it is what turns a mathematical provision into a valeur de
réduction, reduced_capital_pp(t) = V(t) / u(x(t)) [S1] [S8].
Différence de millésime#
The age basis is the calendar year of subscription less the calendar year of birth
[S1] [S8] [S9] — not age last birthday and not age nearest birthday. The true basis
increments on 1 January; age(t) increments at the policy anniversary instead
std, which is exact for a January issue and off by up to one policy year of mortality
otherwise. issue_month is a model point column, 1 on every shipped point, so the
approximation is visible in the data rather than buried in a formula. The décalage d’âge
schedules annexed to art. A. 335-1 CA REG-R23 apply on top of the basis where a
homologated table is used, and are not modelled because no homologated table is shipped.
Inputs are external files#
The six input CSVs live in this directory, beside run.py — not inside the model
folder. Obseques_FR_S/ holds nothing but formulas:
products/obseques/
model_point_table.csv <- inputs live here
mort_table.csv
select_table.csv
lapse_table.csv
surr_scale_table.csv
single_prem_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
Obseques_FR_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.
No CSV column is the model’s t, so none of them moved when the frame became 0-based:
File |
Time-like column |
Decision |
|---|---|---|
|
|
Unchanged. A contractual, 1-based policy-year label; |
|
|
Unchanged, same reason, through |
|
|
Unchanged. An elapsed count from issue — the published quinquennial anniversaries, 0 meaning “at issue” — not the frame’s index. |
|
|
Unchanged. Attained age, reached through |
|
|
Unchanged. Attained age, through |
|
|
Unchanged. Durations — counts of years or months, 0-based by nature. What changed is the comparison at the boundary: |
|
|
Unchanged. Attained ages, compared against |
|
|
Unchanged. A calendar month, 1–12, and not on the frame’s axis at all — it flags the age-basis approximation and enters no formula |
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
File |
Contents |
Provenance |
|---|---|---|
|
Twelve model points. Point 1 is the worked-example anchor cell (RefOBS-VIA / M50 / 5000 € / 336.03 €/year for life / 1.00 % revalorisation / carence 12 months); points 2 and 3 are the other two premium forms; points 4–6 are the premium-linked coupling, réduction at 50 %, and the doubled accidental benefit with a 5 % surrender penalty and an assistance-netted refund; points 7–9 are a second insurer’s viagère rate card, an entry-70 female cell and a 25-year temporaire; points 10–12 are a cessation age of 80, simple revalorisation and monthly instalments |
anchor cell std, technical notes’ worked example; every premium and scale transcribed from [S14] [S5] [S15] [S2] |
|
Annual base mortality by sex × attained age 18–112, capped at 1, with a |
std throughout. An INSEE-shaped Gompertz proxy REG-R24 anchored at |
|
Select uplift by policy year: 1.60 / 1.30 / 1.15 / 1.00 |
std; the direction is defensible on a guaranteed-issue book [S1] [S11] [S12] [S13] R21, the magnitude has no public calibration of any kind |
|
Annual premium-stop rate by policy year: 6 / 5 / 3.5 / 3.5 / 3.5 / 2.5 % |
std; no public French source gives any lapse, surrender or paid-up rate for this product. The declining shape follows from a surrender value worth a fraction of the premiums paid [S14] |
|
Six surrender-value grids in € per 5000 € of capital, by elapsed months from issue; each carries all nine published quinquennial anchors, at 60 to 540 months in steps of 60 |
Transcribed anchors: AXA Serenova viagère and temporaire 10 ans at entry 50 [S14], CNP viagère and prime unique at 50 [S5], Sogecap viagère at 70 [S15], Mutex temporaire 25 ans at 50 [S2]. This file is the source of truth for the provenance of every anchor — its |
|
u(x), the single premium per 1 € of whole-life capital, by attained age 18–112 |
Anchored at 0.854808 / 0.909720 / 0.963912 at ages 50 / 60 / 70 [S5]; interpolated and extrapolated std, clipped to [0.30, 1.00] |
Sign convention#
The technical notes print the stream outgo-positive, so that orientation survives
verbatim as liability_cf(t) and net_cf(t) = −liability_cf(t) exactly — income-positive,
the library-wide sign.
liability_cf = claims_death + claims_death_paid_up + claims_lapse + expenses − premiums
net_cf = −liability_cf
One caveat for a reader checking the worked example by eye: the notes’ table omits
expenses entirely, “for clarity”, and prints premium income, death outgo and surrender
outgo as separate positive columns. So neither net_cf nor liability_cf equals any
column of that table, and the tests assert premiums(t), claims(t, "DEATH") and
claims(t, "LAPSE") against it directly. The notes’ undiscounted totals — premiums
6184.01, death 2941.20, surrender 1005.89, net +2236.92 — are likewise before expenses,
which come to 704.33 over the same horizon.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE: pols_* for
policy counts, plural nouns for cash flows, *_rate for annual rates and *_rate_mth for
monthly ones, *_pp for per-policy amounts, claims(t, kind) and benefit_pp(t, kind)
with uppercase kind strings. The full symbol mapping lives in the Projection Space
docstring. Five cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
Two population strands, because réduction keeps a liability rather than ending one; on every point where |
|
|
The per-policy paid-up capital, and the aggregate in force — which is what removes the need for a per-conversion cohort dimension |
|
|
The model point’s choice of published grid, and the interpolated amount off it. Separate cells because mixing one insurer’s premium with another’s grid is the easiest wrong answer on this product |
|
|
The notes’ single column is the sum of the two strands; the library publishes one column per |
|
|
|
Standardizations used#
Everything in this list is std: the mortality proxy and its 0.60 female factor; the
1.25 anti-selection loading and the 1.60 / 1.30 / 1.15 select uplift; the mortality
improvement dial (zero in base, a flat annual rate as the proxy, since France has no
publicly available insured-lives projection model comparable to the CMI’s); the lapse
table; the 5 % accidental share of deaths; reduction_share at 0 in base with 0.5 and 1.0
as the variations; the monthly conversion q_m = 1 − (1 − q)^(1/12); the limiting age
omega_age = 112 and mortality forced to 1 there; the anniversary age step in place of the
1 January millésime step, with issue_month = 1 on every shipped point so that the
approximation is exact where the data says it is; linear interpolation of both external
scales in elapsed months and the month-0 anchors; compounding rather than simple revalorisation; annual premiums in
advance with the instalment options carried as a 2.2 % loading [S11] rather than a
re-tariffing; acquisition 150 € at issue and maintenance 24 €/year inflating at 1.8 %;
death-before-premium-stop as the processing order; and the overrun lapse stress dial.
Deliberately excluded, per the notes: the post-mortem revalorisation between death and payment [S1] [S8] R8 REG-R31 and the statutory payment clock of art. L. 132-23-1 CA — settlement-lag refinements; capital increases, anti-selective on a guaranteed-issue book and mitigated but not removed by the fresh waiting period on the increment [S1] [S8]; the 30-day renonciation with a full refund [S1] [S8] REG-R29, modelled as never-issued business outside the projection; the 40-day suspension of cover during the formal-notice window [S1], where ignoring it is the conservative choice; aggregate capital caps per insured [S1] [S8] [S12], which bind across contracts rather than per policy; and claim handling costs, folded into maintenance because no retrieved document separates them from disclosed charges.
There is no maturity kind and no account value. The contract ends only on death, on rachat or on lapse [S1] [S8] [S9] [S11]; maturity outgo is identically zero because there is no maturity, and a test asserts that none of the account-value chassis exists here.
Tests#
tests/test_obseques_fr.py asserts all fifteen rows of the notes’ worked example to the
cent and the in-force column to five decimals — re-keyed to the 0-based frame, values
unchanged — the undiscounted totals over the full 756-month horizon t = 0 … 755, the
t = 11 / t = 12 carence step and its three-factor decomposition, the
notes’ own closed-form survivorship checks at the first two anniversaries, the capital and
the surrender value at t = 204 two ways, and then one test for each of the twelve
modelling pitfalls the notes list — each named for the failure it catches. A further test
pins all nine published quinquennial anchors on all six shipped surrender grids,
against the model as well as against the CSV, because an anchor silently replaced by an
interpolant between its neighbours is a shipped input 11 % away from the figure the
documents claim to transcribe. Five check_*
cells run over every model point in the conventions suite: the in-force roll-forward across
both strands, the annual survivorship identity, the capital roll-forward, the premium-stop
gate and the truncation residual at the limiting age.
python -m pytest tests -q