Skip to content

Alloc rule 2 bug - #412

Open
Ha-Ree wants to merge 2 commits into
developfrom
fix/issue-2055-fmcalc-alloc-rule-2-layers
Open

Alloc rule 2 bug#412
Ha-Ree wants to merge 2 commits into
developfrom
fix/issue-2055-fmcalc-alloc-rule-2-layers

Conversation

@Ha-Ree

@Ha-Ree Ha-Ree commented Sep 2, 2026

Copy link
Copy Markdown

Fixes the back-allocation half of OasisLMF#2055 — under -a2, per item losses depend on which policy happens to be layer 1, so per location IL depends on the row order of account.csv. Also fixes a segmentation fault in the same code path.

The bug

compute_item_proportions has two ways of working out the item proportions used to back-allocate a level's loss:

  • alloc rule 3 computes them at every level and layer as the calculation walks up the hierarchy
  • alloc rule 2 computes them lazily, only once it reaches the final level

Under rule 2 nothing ever fills in agg_vecs[level][layer].item_prop for the intermediate levels of layers above 1, so when the final level chains through the level below it finds a null and falls back to layer 1's proportions:

if (prev_agg_vec[i].item_prop == nullptr) {
    prev_agg_vec[i].item_prop = prev_agg_vec_base[i].item_prop;   // layer 1
}

Every layer therefore gets back-allocated using layer 1's distribution of loss over items. Since layer_id follows the row order of account.csv, reordering the file changes which policy is layer 1 and so changes every layer's item split. Rule 3 was unaffected because it never leaves the gap.

The two rules were never intended to differ. Rule 3 was added in 7bbb5a6 as a lazy variant of the existing rule, and 817eee8 ("switch alloc rules 2 and 3") swapped the numbering so the lazy implementation became the default 2. The loop bodies carry identical comments.

Second bug in the same place

The layer 1 pass skips aggregations with no loss (allowzeros == false on the sampled path), leaving their item_prop null. If a later layer does have loss at such an aggregation, the fallback above hands it a null pointer and fmcalc dereferences it. Released 3.12.4 segfaults on 8 of 250 random multi-layer structures under -a2.

The fix

In the per-layer branch of compute_item_proportions:

  1. If this layer's proportions for the previous level are missing, compute them (recursing down the levels) instead of falling back to layer 1.
  2. At level 1, always populate the ground up share proportions. They are the same for every layer, so this both provides the base case for (1) and removes the null dereference.

Alloc rules 0, 1 and 3 are untouched.

Verification

check result
OasisLMF#2055 reproduction, -a2 was 161,418 / 112,220 for the two row orders, now 148,786 for both — byte identical to -a3 and to fmpy
400 randomly generated FM structures, -a2 vs -a3 identical on every value
Same, -a2 vs -a2 -O and -a2 -o identical (the optimisation flags change how often the proportions are recomputed)
Same, -a2 vs fmpy -a2 identical bar 5 cases that are unchanged by this PR — fmcalc emits 0 where fmpy emits a value, the documented #54 "final losses can be set to zero if prior level losses are zero" behaviour
Same, baseline vs fixed -a2 differs on ~20% of structures, and the fixed binary runs 8 that the baseline crashed on
All 14 OasisLMF validation/ cases, -a0/1/2/3 output unchanged — none of them has layer specific terms below the top level, which is why this went unnoticed
make check passes; the unfixed binary fails exactly the two new _alloc2.csv checks

Existing runs are only affected where a programme has more than one layer and layer specific terms below the top level that distribute loss over items differently. Single layer programmes, and layers whose terms are all blanket, are bit for bit unchanged.

Performance

No measurable change on single layer structures. About 15% slower on a 4 level, 4 layer structure, which is the cost of doing the per-layer work that rule 3 always did — still cheaper than rule 3, which recomputes the layer 1 chain at every level.

Tests

ktest had no back-allocation coverage at all: fmcalc was only ever run with the default -a0. This adds two small cases under examples/fm_alloc, each run through -a1, -a2 and -a3:

  • case1 — 4 items, 3 levels, 2 layers, with layer specific coverage limits that make the two layers distribute their loss over the two locations in opposite proportions. Small enough to check by hand; the arithmetic is in the README. This is the regression test for the ordering bug.
  • case2 — 5 items, 2 levels, 3 layers, where layer 1 has no loss at an aggregation that a later layer does. This is the regression test for the segfault.

The expected -a2 and -a3 outputs are identical, which is the invariant this PR restores.

Docs

One sentence in docs/md/fmprofiles.md recording that rules 2 and 3 are the same calculation with different evaluation strategies, since the table's "(reinsurance)" label reads as though they differ.

runtests.sh and ctrl.sha1 already reference these files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Per-location IL depends on accounts.csv row order under ktools_alloc_rule_il=2 (layer_id follows row order; rule 2 back-allocates all layers by layer 1)

2 participants