Alloc rule 2 bug - #412
Merged
Merged
Alloc rule 2 bug#412
Conversation
runtests.sh and ctrl.sha1 already reference these files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
approved these changes
Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofaccount.csv. Also fixes three segmentation faults in the same function, two of which 3.12.4 already has.The bug
compute_item_proportionshas two ways of working out the item proportions used to back-allocate a level's loss:Under rule 2 nothing ever fills in
agg_vecs[level][layer].item_propfor 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:Every layer therefore gets back-allocated using layer 1's distribution of loss over items. Since
layer_idfollows the row order ofaccount.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
7bbb5a6as a lazy variant of the existing rule, and817eee8("switch alloc rules 2 and 3") swapped the numbering so the lazy implementation became the default2. 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 == falseon the sampled path), leaving theiritem_propnull. 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_proportionscopiesagg_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-a3as 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 nullitem_idx. 3.12.4 hits this under-a3.The fix
In the per-layer branch of
compute_item_proportions:case2).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).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
-a0reports directly. It needs no reference implementation to bebelieved.
-a0gross per layer-a1,-a2and-a3, no crashes. Before this PR's final two commits,-a2crashed on 14 and lost loss on 10 of the remainder, and-a3lost loss on 19-a2vs-a3-a0and-a1develop— neither rule is touchedreinsurance_tests/targetstructures (ri1,ri2,ri2_2layer,ri2_2layer_2level),-a0/1/2/3develop.ri2_2layer_2levelis 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 theremake checkdevelopfailscase1 -a2,case2 -a2,case3 -a2,case4 -a2/-a3andcase5 -a3; the previous head of this branch failscase3 -a2,case4 -a2/-a3andcase5 -a2/-a3The #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
RunExposureon oasislmf 2.5.6, all threebinaries — 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
-a2and-a3. Thegenerated
fm_policytc.binandfm_profile.binstill differ between the two row orders, so thereversal 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/case5are what standbehind the fix now.
The saved FM inputs from the original reproduction are still a useful direct test, and they are
where
case5came from: 3.12.4 runs them under-a2but faults under-a3, the previous head ofthis 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_testsstructures.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:
fmcalcwas only ever run with the default-a0. This adds five small cases underexamples/fm_alloc, each run through-a1,-a2and-a3. The arithmetic for each is in the README:-a2without fix (3).-a2and-a3alike, which is why this case is the regression test for rule 3.-a3on 3.12.4 and under both-a2and-a3on the previous head of this branch.For case1 to case4 the expected
-a2and-a3outputs are identical, which is the invariant this PR restores. case5's-a1output is pinned as a change detector rather than as a correct answer:-a1allocates 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.mdrecording that rules 2 and 3 are the same calculation with different evaluation strategies, since the table's "(reinsurance)" label reads as though they differ.