Skip to content

Alloc rule 2 bug - #412

Merged
sambles merged 4 commits into
developfrom
fix/issue-2055-fmcalc-alloc-rule-2-layers
Sep 28, 2026
Merged

sambles merged 4 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
Contributor

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 three segmentation faults in the same function, two of which 3.12.4 already has.

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.

Three more bugs in the same function

Chasing the first one surfaced three others, all of which 3.12.4 has:

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. 3.12.4 segfaults on 8 of 250 random multi-layer structures under -a2.

A sibling branch borrows layer 1's proportions at the same level. Where the level below does not carry the current layer, compute_item_proportions copies agg_vecs[level_][1]'s proportions rather than computing this layer's. When layer 1 has no loss at that level its proportions there are all zeros, so every other layer copies the zeros and allocates nothing: the layer's loss is not redistributed, it disappears, and back-allocated losses sum to zero against a non-zero layer loss. This one affects -a3 as much as -a2, and it is why rule 3 is no longer untouched by this PR.

An item with no entry at the previous level indexes with -1. The item to previous-level-index lookup leaves -1 wherever the previous level's loss vector does not cover an item, which happens when that level's aggregation ids are not contiguous. The -1 was then used to index that vector, both when totalling the previous level's loss and when reading back item_idx, so fmcalc read out of bounds and faulted on a null item_idx. 3.12.4 hits this under -a3.

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 where layer 1 skipped a zero loss aggregation (case2).
  3. Restrict the same-level copy shortcut to level 1. A sibling branch had the identical defect: where the level below does not carry the current layer, it copied layer 1's proportions at the same level instead of computing anything. Level 1 is the only level with no level below to compute from; above it the proportions come from agg_vecs[level_ - 1][1], which every layer above shares. This is what fixes the dropped layer losses and the remaining crash of that shape (case3, case4).
  4. Skip items that have no entry at the previous level. The item to previous-level-index lookup leaves -1 for any item the previous level's vector does not cover, which happens when its aggregation ids are not contiguous, and the -1 was used unchecked both to total the previous level's loss and to read back item_idx (case5).

Alloc rules 0 and 1 are untouched. Rule 3 is not — items 3 and 4 fix rule 3 as well as rule 2, because both rules reach the same two branches. That is a change from the earlier revision of this PR, which claimed rule 3 was unaffected.

Verification

The test that matters most is conservation: the back-allocated item losses for a layer must sum to
that layer's gross, which -a0 reports directly. It needs no reference implementation to be
believed.

check result
300 randomly generated multi-layer structures, back-allocated total vs -a0 gross per layer conserved on all 300 under -a1, -a2 and -a3, no crashes. Before this PR's final two commits, -a2 crashed on 14 and lost loss on 10 of the remainder, and -a3 lost loss on 19
Same 300, -a2 vs -a3 identical on every value
Same 300, -a0 and -a1 byte identical to develop — neither rule is touched
reinsurance_tests/target structures (ri1, ri2, ri2_2layer, ri2_2layer_2level), -a0/1/2/3 byte identical to develop. ri2_2layer_2level is exactly the at-risk shape — one layer at level 1, two at level 2 — and is unchanged because layer 1 has loss at every aggregation there
make check 425 checks pass. develop fails case1 -a2, case2 -a2, case3 -a2, case4 -a2/-a3 and case5 -a3; the previous head of this branch fails case3 -a2, case4 -a2/-a3 and case5 -a2/-a3
OasisLMF#2055 end-to-end reproduction no longer reproduces on current OasisLMF, on any binary. See below

The #2055 reproduction no longer discriminates

The earlier revision of this description reported 161,418 / 112,220 for the two row orders against
148,786 after the fix. Re-running it today through RunExposure on oasislmf 2.5.6, all three
binaries — 3.12.4, the previous head of this branch, and this one — give L1=148,786.77 and
L2=342,962.73 for both row orders, and report order-independent for both -a2 and -a3. The
generated fm_policytc.bin and fm_profile.bin still differ between the two row orders, so the
reversal still changes the structure; it no longer changes the answer. Upstream input generation
has moved since this branch was opened, so that end-to-end case cannot be used as evidence either
way any more. The 300-structure conservation result and case3/case4/case5 are what stand
behind the fix now.

The saved FM inputs from the original reproduction are still a useful direct test, and they are
where case5 came from: 3.12.4 runs them under -a2 but faults under -a3, the previous head of
this branch faults under both, and this branch runs all four rules and conserves.

Existing runs are only affected where a programme has more than one layer and either layer specific terms below the top level that distribute loss over items differently, or a layer with no loss at a level where a later layer has some, or non-contiguous aggregation ids at a level. Single layer programmes, and layers whose terms are all blanket, are bit for bit unchanged — as are all four reinsurance_tests structures.

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. Rule 3 now does the same per-layer work at the two branches it previously short-circuited, so it is marginally slower on those shapes too.

Tests

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

  • 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 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. The regression test for the first segfault.
  • case3 — 2 items, 3 levels, a single layer at level 1 and two above it, layer 1 wiped out at the top level. The recursion terminates in the same-level copy branch with layer 1's proportions absent. Faults under -a2 without fix (3).
  • case4 — case3 with a single layer at level 2, so the top level takes the copy branch directly and copies layer 1's zeros. Layer 2's entire 4000 loss was dropped rather than allocated, under -a2 and -a3 alike, which is why this case is the regression test for rule 3.
  • case5 — 2 items, 4 levels, 4 layers, reduced from the #2055 client structure by dropping items until the fault stopped reproducing. Level 1's aggregation ids are not contiguous, so an item has no entry at the previous level and the -1 placeholder was used as an index. Faults under -a3 on 3.12.4 and under both -a2 and -a3 on the previous head of this branch.

For case1 to case4 the expected -a2 and -a3 outputs are identical, which is the invariant this PR restores. case5's -a1 output is pinned as a change detector rather than as a correct answer: -a1 allocates 54.41 to a layer whose gross is 92.50 on that structure, which is a separate pre-existing problem in the rule 1 path and is not addressed here.

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>
Ha-Ree and others added 2 commits September 14, 2026 16:12
compute_item_proportions had a second branch with the same defect as the one
already fixed on this branch: where the level below does not carry the current
layer, it copied layer 1's item proportions at the same level instead of
computing them. Restrict that shortcut to level 1, which is the only level with
no level below to compute from; at any higher level the proportions are
computable from agg_vecs[level_ - 1][1], which every layer above it shares.

This removes the two problems the earlier commits left behind:

- the null dereference, for the shape where an intermediate level has more
  layers than the level beneath it and the recursion terminates in the copy
  branch with layer 1's proportions absent
- a whole layer's loss being dropped where layer 1's proportions at that level
  are all zeros, so every other layer copied the zeros and allocated nothing

The second of these affects alloc rule 3 as well as rule 2, so rule 3 is no
longer untouched by this branch. Over 300 randomly generated multi-layer
structures, back-allocated losses now sum to the -a0 gross for every layer
under -a1, -a2 and -a3, with no crashes; before this commit -a2 crashed on 14
and lost loss on 10, and -a3 lost loss on 19. Deleting the branch outright does
not work because rule 3 calls into level 1.

case3 and case4 pin the two shapes. Without this commit case3 -a2, case4 -a2
and case4 -a3 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
compute_item_proportions builds an item to previous-level-index lookup from
agg_vecs[level_ - 1][previous_layer_], leaving -1 for any item that vector does
not cover. That -1 was used unchecked: once when inserting into the set used to
total the previous level's loss, and once when reading back item_idx to find the
item's position. Both read out of bounds, and the second dereferenced a null
item_idx and segmentation faulted.

An item is left unmapped when the previous level's aggregation ids are not
contiguous, so the loss vector holds entries that no item maps to. Released
3.12.4 faults on this under -a3; the per-layer recursion added earlier on this
branch reaches the same code from more places and made it fault under -a2 too,
so this is a regression on this branch as well as a pre-existing crash.

Unmapped items now contribute nothing to the previous level total and take no
share, which is the only meaning available - there is no prior level loss to
allocate in proportion to. On the reduced structure in case5 both -a2 and -a3
now back-allocate the full layer gross of 520.00 and 92.50.

case5 is that structure, reduced from the OasisLMF#2055 client data by dropping
items until the fault stopped reproducing. Without this commit case5 -a2 and -a3
fail; 3.12.4 fails case5 -a3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sambles
sambles merged commit 4a7506c into develop Sep 28, 2026
2 checks passed
@sambles
sambles deleted the fix/issue-2055-fmcalc-alloc-rule-2-layers branch September 28, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

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)

3 participants