Repository navigation
Conversation
…into fix/2541-ca-earnings-pence
is_gainfully_employed_for_carers_allowance floored the weekly pence in float32, where the 1e-6 tolerance does nothing near 20,000 pence, so float32 rounding decided cases within about 0.002 pence of a boundary. Earnings of 52 x L + 0.52 a year (a penny a week over) counted as within the 2020-21 to 2024-25 limits of 128, 132, 139 and 151. The formula now takes the stored annual earnings to the nearest penny in float64 (an exact half penny disregarded, reg. 5), spreads them over 52 weeks in whole pence with the fraction disregarded (reg. 5), and compares with the limit in pence (reg. 8(1)). Tests: every limit in earnings_limit.yaml in every year it applies, entered as earnings and as pay; a pounds-and-pence reformed limit; property tests for exact whole-pence judgement, agreement with exact rational arithmetic and monotonicity; YAML cases for 2024-25. The property-test helpers now restate the new rule in Fraction arithmetic. Fixes #2541 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Aggregate impact on certified data: passBase Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in Notes
Moves flagged for reviewNone. Receipts by tax head
Spending by benefit and service
Caseloads
Government balance, each component counted once
Model totals
Household income totals
Inequality
Mean household net income in each side's own deciles
Standard reform costings (change in gov_balance)
The report suppresses a reform's costing and target change in these years, for the reasons below (
Standard reforms: change in each reform's target
Household net income by decileNo household's net income changes in any year. No change in any year: population; poverty rates; people in poverty. Checks failing on both base and head (1)
Counted-once checkEach of 92 components was raised by £1 per unit in 2026-27, and every head of its totals was read household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A head that breaks a rule is measured again twice: with every other head held at its baseline value, and with every other head held at its value after the first £1 while the component rises again. It fails where either still shows the response. 0 failed; 0 were skipped.
Provenance
Dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
The #2543 review found that taking stored earnings to the nearest penny misjudges whole-pence pay less half of an odd-pence pension contribution. Net earnings of 52 x L + 0.515 are 52 x L + 0.51 in law, because reg. 5 counts the half penny of the reg. 10(4)(b) deduction as a penny. Float32 holds that value just above the half penny for most limits, so nearest penny counted it up and over the limit, at all ten limits. The formula now takes the nearest half penny (an exact quarter penny down), disregards a half penny, then divides by 52 in whole pence (1996 reg. 8(1)(b)(iii) with reg. 5). np.floor(x / n) replaces // so that infinite earnings stay over the limit. Every whole-pence and half-penny amount below 65,536 pounds is recovered exactly. Tests: odd-pence contributions at every limit in every set year, a Hypothesis property checking pay less half a contribution against the law in integer arithmetic, YAML Cases 23 and 24, float32 values either side of 52L + 0.515 and 52L + 0.5175 as explicit examples, and boundary examples for every #2541 limit. The Fraction references follow the new rule. changelog.d/2532.fixed.md (from #2534) no longer describes this head, so it is removed; the wording fixes the review asked for are included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into fix/2541-ca-earnings-pence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE
Review severity: REQUEST_CHANGES
Reviewed head: a67d545
C1 — Nearest-half-penny rounding can remove the entire allowance for valid self-employed earnings. In is_gainfully_employed_for_carers_allowance.py:61–63, the new step can round computed net earnings upward even when the supplied income and pension inputs are whole-pence amounts.
Executed case: an otherwise eligible 45-year-old carer in England in 2026, providing 40 qualifying care hours weekly, with £14,000.01 self-employment profit, £6,039.38 personal pension premiums, no employed income and no overlapping benefit. No eligibility or intermediate amount is overridden.
Both the current calculation and a prior-formula-only control produce stored net earnings £10,608.517578125. The new formula promotes this to £10,608.52 and pays £0 Carer’s Allowance; reverting only that formula pays £4,495.40.
Computation of Earnings regulation 5 requires fractional pennies to favour the claimant. With regulations 13 and 14, claimant-favourable deductions are £286.01 tax, £85.81 Class 4 and £3,019.69 pension. Annual earnings are £10,608.50, giving £204.00 weekly after disregarding the fraction. This is within the strict 2026 earnings limit, so the £4,495.40 award remains due. Rounding a combined deduction instead leaves £10,608.51 and the same eligibility; even the existing unrounded approximation is below £204.01 a week.
Preserve the whole-pence and odd-pension fixes while ensuring computed off-grid earnings receive claimant-favourable rounding. Add this actual-input regression and update the corresponding bridge/case in queued Axiom issue #482. The disclosed generic approximation does not cover this demonstrated full-award regression, and the prior-formula control isolates this PR's change.
Validation: 66 Python tests and 24 changed YAML cases pass, with no failures or skips. The original interrupted run was recovered with native exit 0; it was not repeated. The additional current/prior diagnostic separately exited 0. Twenty source originals and extracts were checked. Review scope is GitHub's five actual PR files, with the comparison base qualified independently of the shallow local history. No local population run, full suite or proposed repair is claimed.
|
Heads-up on a semantic interaction with #2699 and #2700. There's no file overlap.
Suggested change: take years after the last YAML date from the model, which keeps the "every limit in every year" coverage: def _limits_by_year(last_year):
limits = []
for year in range(LIMIT_VALUES[0][0].year, last_year + 1):
if year <= LIMIT_VALUES[-1][0].year:
limit = [v for date, v in LIMIT_VALUES if date <= datetime.date(year, 4, 30)][-1]
else:
limit = system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit
limits.append((year, limit))
return limitsRestrict the set-equality assertion to 🤖 Generated with Claude Code |
juaristi22's review of #2543 found a trader within the limit whom the nearest-half-penny rule put over it: £14,000.01 of profit and a £6,039.38 premium leave £10,608.5174 unrounded, which the half-penny step took to £10,608.52 (£204.01 a week). Reg. 5 counts a fraction of a penny in each deduction as a penny, giving £10,608.50. carers_allowance_earnings now works in float64 pence from the whole pence each amount was entered as (utils.pence.stored_pence), taking pay and profit down to a whole penny and each deduction (PAYE, Class 1, notional tax, Class 4, Class 2, half of pension contributions) up to one. Net earnings are whole pence, which is_gainfully_employed_for_carers_allowance reads back exactly before dividing by 52. Tests: stored_pence round-trips every whole-pence amount below £131,072 and moves an amount at most half a float32 step; a through-the-model test checks trading and employee net earnings against the law, deduction by deduction, in exact arithmetic, with the review case; YAML Case 25 pins it. The limits test now reads projected limits from the model after the last legislated one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pence # Conflicts: # policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py
|
@juaristi22, thanks. C1 was right, and it went deeper than the half-penny step. Rounding the stored total can't separate whole-pence earnings from genuine fractions of a penny: float32 holds a £14k–£20k profit only to within about a tenth of a penny before tax comes off, so the two overlap. Even a two-step tolerance misjudged 39 of 1,779 traders within 5p of the 2026-27 boundary. So 32f1639 applies reg 5 where the calculation happens, at each step:
Your case is £14,000.01 of profit and a £6,039.38 premium in 2026-27. The deductions are notional tax £286.002, rounded up to £286.01; Class 4 £85.8006, rounded up to £85.81; and half the premium, £3,019.69. That leaves £10,608.50, or £204.0096 a week, which is not over £204, so CA is £4,495.40. It is now covered in three places:
The PR body has the full rule, the sweeps and the wrong-rule checks. rulespec-uk#482's bridge and companion row are updated to match. |
|
Checked: at 32f1639 this merges cleanly onto #2699 (3c19621) and onto #2700 (21f0fa85f). Its CA tests pass on both:
That includes #2700, where the limit rises to £212 from 2027-28. The |
Review of #2543 at 32f1639 (GPT-6.1 Sol lanes closed; Opus reviewer): - Class 1: the model sums twelve float32 months, which can hold a whole-penny total (£175.56) further out than stored_pence recovers, so rounding up deducted a penny too much and put employees exactly a penny a week over the limit within it. carers_allowance_earnings now works the model's Class 1 formula exactly on the year's NI income in whole pence, where it agrees with the stored total to within float32 error, and otherwise reads the stored total. - Pension: read pension_contributions, summing its programmes again in float64 only where the stored total is their float32 sum (stored_sum_pence), so a total entered directly still comes off. - Docs: PAYE and Class 1 rounding is the claimant-favourable convention, reg. 5 governs the calculated deductions; the self-employed weekly amount cites reg. 11(1)(a); CSP reads these earnings too. - Tests: employee-route Class 1 expectation worked exactly, with the review's rows; invariant 3 restated (2p rises never lower net earnings, a 1p rise can, as reg. 5 intends) and checked over every penny across each threshold; entered pension totals; stored_sum_pence; deadline=None on the stored_pence property tests; YAML Cases 26-31 and two CSP cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A float32 sum of the two programmes can lie more than half a step from its pence; read alone, half of it would round up a penny too far. This case fails if stored_sum_pence reads a sum as stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the three findings of the review of 8f1df68, and a fourth of the same kind found while fixing them: - Class 1 entered as an input (nil, or any amount near the formula's) was replaced by the formula worked exactly whenever the two lay within float32 error. Class 1 is now worked out again only where the model calculated it with its own formula: no reform changed how it or its parts are calculated, and no month was supplied or stored by a scenario. Otherwise the stored amount is the one deducted (reg. 10(4)(a)). - Sums nested in sums (employee_pension_contributions inside pension_contributions, and employment_income itself) kept float32 noise. amount_pence now recurses through adds/subtracts of amounts the simulation derived, reading each as stored_pence does, and takes their exact sum where the float32 total lies within the rounding of the additions that formed it. Pay is read this way too: 10,276.42 + 1,415.69 is stored as 11,692.109375 and was floored to 11,692.10. - A formula in place of a sum (adds=None) crashed the reader; a variable with a formula, defined_for, another period or another entity's part is now read as stored. YAML Cases 33-36 pin the four executed cases, each a penny a week over the 2026-27 limit; model-level tests pin entered and reformed Class 1, a parameter reform, a formula reform and 1,203 sums the model forms (600 with Class 1, 603 without NI), and the every-year test checks Class 1 against an independent Fraction restatement from 2015 to the horizon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three of the 22 wrong rules swapped in at 6e8a53f failed no test: - Employee pension contributions read as stored for PAYE. test_sums_the_model_forms_are_read_in_whole_pence gains a PAYE regime: 600 sets of inputs with pay less member contributions over the personal allowance, mostly by a multiple of 5p, so that 20% of it is whole pence and contributions misread by a fraction of a penny change the tax. - The reader's input check removed. A pension total entered as £3,242.6401 is the very float32 that programmes of £1,807.49 and £1,435.15 sum to; only the input check tells them apart, and the entered total is read as entered (£10,608.51, within) where the programmes alone give £10,608.52 (over, CA Case 32). - The reader's formula check removed. policyengine-core refuses a variable with both a formula and adds, so the check could never be true; it is dropped, and a test pins the core behaviour it relied on. The fourth survivor, the Class 1 tolerance backstop, is intended: the exact path restates the model's own formula, so the two can only disagree if one is edited without the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Heads-up from #2719 (Budget 2026 measures, draft until Budget week ends): its Carer's Allowance taper commit (b5d2d77) moves the weekly-pence step out of 🤖 Generated with Claude Code |
Fixes the r7 review's findings on PR #2543 (refs #2541): - Class 1 entered for the year (which core divides among the months in float32) or month by month was read from the float32 annual sum, so £1.20 (or 12 x 10p) was deducted as £1.21. amount_pence now sums a monthly flow's months exactly (months_summed), recovering a year's whole pence within the rounding of the division; the Class 1 fallback and backstop read it that way. - The exact Class 1 path accepted a reform that ended the primary formula or dated it from 2027, since it compared formula code alone. It now compares, month by month, the formula core runs (code, the globals it reads, defaults, closure), the variables' other calculation metadata against the model's own classes, abolition by gov.abolitions, and how the formulas read NI income, liability and directorship. - A sum, or Class 1, that the cache policy declines to keep counted as supplied (is_derived is false for an unstored value). Inputs are now told by the holder's input periods. Tests: the reviewer's reproducers, 400-carer sampled property tests of Class 1 entered in whole pence (year and months), reforms that end, re-date, abolish or hold in whole pounds the primary part, run its code with another global value, or change how it reads liability or NI income, cache-policy invariance, and months_summed invariants (exhaustive recovery of a year's whole pence below £32,768). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The r7 review's reproducer: £1.20 entered with £10,609.72 of pay leaves £10,608.52, a penny a week over, not £10,608.51. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tment The r7 review noted that _natural_rows called its reference legal net earnings, though the model counts statutory sick pay in NI income only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop. Generated by Claude Code |
Main moves the weekly-pence step out of is_gainfully_employed_for_carers_allowance into carers_allowance_weekly_earnings_above_limit, which feeds both the gainful-employment test and the Budget 2026 earnings taper. The exact whole-pence comparison now sits in that variable: it reads the whole pence of carers_allowance_earnings back with stored_pence, divides by 52 with the fraction disregarded, and returns the excess over the limit in whole pence. The gainful-employment test keeps main's form, any excess. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6FNwMQX6ZjTDMqg73X
The Budget 2026 earnings taper reads carers_allowance_weekly_earnings_above_limit with stored_pence, so it takes off exactly the whole pence above the limit. New property tests check, for each limit from 2020-21 to the horizon with the taper applied, that 1p, 2p and £1 a week over the limit give that excess and the rate less it, and that the taper never pays more than the full rate, never goes negative, falls by at most £1 for each £1 above the limit and pays the full rate at or under it. The changelog fragment names the new variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6FNwMQX6ZjTDMqg73X
From 2030-31 the Budget 2026 earnings taper pays a carer a penny a week over the limit the rate less a penny, so the every-year penny-step test now switches the taper off to test the cliff edge; the taper's own every-year test covers the same steps with it on. The shard durations no longer name test_net_earnings_never_fall_as_pay_rises, which this branch replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6FNwMQX6ZjTDMqg73X
|
Ready for hub review Head: 1. Merge and where the pence step lives
2. Checks on the merged logic
3. Tests (taper properties are in
Revert check. In a scratch copy, I restored main's float32
I didn't run a separate check that removes only the taper's 4. PR body
Skipped
Generated by Claude Code |
|
Ready for hub review Verdict: APPROVE WITH NITS at I reviewed The code is right. In every year from 2020 to 2041 the excess is exactly 1p, 2p and 100p for a carer 1p, 2p and £1 a week over the limit, and the merge resolution is equivalent to the PR's own test. The findings below are about two tests and some text. No finding gives a wrong result. P1None. P2P2-1. The hand-off is wrong that no test can detect the taper's
P2-2. The "never negative" property can't fail when the taper's own floor is removed.
P2-3. The PR body's effect table still gives the 2027–2028 limits as £196 and £204.
P3P3-1. Stale "#2699 proposes" in the body's Tests section.
P3-2. A comment and a docstring say the excess is always whole pence.
P3-3. The changelog fragment breaks the length rule and starts a sentence with a lower-case identifier.
P3-4. Writing rules in the PR body and documentation.
P3-5. The body doesn't describe how Class 1 is chosen.
Checked and sound
M1 confirms the body's figures: under main, 1p over gives 0p in 2020-21 to 2024-25 and 0.99945068p from 2025-26. I printed the latter directly for 2026–2029.
What I couldn't check
Generated by Claude Code |
The taper property test now also checks carers_allowance_pre_couple_limit stays between zero and the full rate, so removing the taper's floor fails a test. The taper comment says stored_pence makes the subtraction run in float64; the gainful-employment comment and the excess documentation say the excess is whole pence only for a limit in whole pence. The changelog fragment is cut to two sentences, and test comments no longer name review work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N2tyHS2ac7YnLjbcxD5W58
|
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop. Generated by Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N2tyHS2ac7YnLjbcxD5W58
|
Generated by Claude Code |
|
Generated by Claude Code |
|
Ready for hub review Head: FindingsP2-1, the taper's P2-2, the taper's floor. P2-3, the effect table. I printed the limits from the parameter tree: 2027 to 2029 give £212, £217 and £222. The 2041 limit is £309 (from 2039), and the taper share is 0.4986301 in 2030 and 1 from 2031. I replaced the row with the review's two rows. I reran the cases at this head and on main (46a6067, scratch worktree): 52L + £0.52 is gainfully employed with CA nil in 2025 to 2029 on both. The excess is exactly 1p here and 0.99945068p on main. The review's 2030-31 sentence needed more care. Invariant 11 tests the rate less the excess with the taper applied for the whole of every year by reform, but the baseline 2030-31 award is part-year. So after the table I kept the review's sentence and added what I reran: £2,500.44 a penny over £228 in 2030-31 (182 of 365 days) and £5,114.90 a penny over £234 in 2031-32. Main gives the same two awards, because 0.99945p moves the annual award by less than a float32 step. The body says so. P3-1. Applied in the body's Tests section ("the model's projected limits from 2027-28 (£212, rising to £309 from 2039-40) are covered"). P3-2.
P3-3, the changelog (
P3-4, process text.
Counts cut because no repository test reproduces them and I couldn't rerun them (the scripts aren't in this session):
Counts I kept, each reproduced by a test or rerun:
Two body claims were wrong at current main and are corrected:
The CSP figure (main £10,608.5225, CSP nil) checks out on main. Invariant 8 said P3-5. I read
Main. Tests at 32093c6
Mutants at this head: a scratch worktree, each restored afterwards. The tests are the taper module plus the two every-limit tests, 25 in all.
Skipped, and judgement calls
Generated by Claude Code |
|
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop. Generated by Claude Code |
summed and _step take the float32 step above a zero bound, which is the smallest subnormal float32; numpy reports that as underflow, so a model run under np.errstate(all="warn") with RuntimeWarning as an error failed test_the_divisions_the_filters_hid_raise_no_runtime_warning. Wrap only the step computations in np.errstate(under="ignore"), and test zero amounts under that strict state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6K3MjWdnKkhbAC6yks
|
Generated by Claude Code |
|
Generated by Claude Code |
|
Ready for hub review Head: Findings1. Reproduced.
2. Which step computations underflow. Each called on zero amounts and zero bounds under
Only 3. The fix ( 4. Tests (
5. The body.
Tests at df78930 (
|
| Check | Result |
|---|---|
test_warning_filters.py |
32 passed |
test_stored_pence.py |
11 passed (9 + 2 new) |
test_carers_allowance_earnings_limit_properties.py |
59 passed |
test_carers_allowance_weekly_earnings_above_limit_properties.py |
23 passed |
test_carers_allowance_pension_contribution_deduction_rate_properties.py |
2 passed |
test_carers_allowance_earnings_taper.py |
68 passed |
test_carers_allowance_earnings_limit_projection.py, test_carer_support_payment.py (for the body's list) |
8, 10 passed |
YAML policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml |
37 passed |
YAML policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml |
19 passed |
YAML, 22 named / 72 mentioning carer or care_hours |
308 / 1,318 passed |
policyengine_uk/tests/code_health |
3,088 passed |
.github/check_fix_pr.py, final title and body, empty labels, changes from merge base with main, --head-sha df78930 |
PASS, 6 changed executable tests |
Ruff 0.17.2 format --check . (2,167 files), check ., check --select F811,F821 policyengine_uk |
clean |
git diff --check 32093c6cf..HEAD |
clean |
Main. It moved while I worked, to 0080274d7. git merge-tree --write-tree is clean against it and against 6c6e229a5. Main doesn't touch test_warning_filters.py or pence.py. On the merge with 0080274d7, uncommitted in a scratch worktree, test_warning_filters.py + test_stored_pence.py give 43 passed. I didn't merge main into the branch.
Skipped, and judgement calls
- Skipped: aggregate impact and survey-data tests (no dataset here). Not rerun: the M1–M8 mutants, because the fix doesn't touch the excess or the taper.
- Judgement call: two comments, not one. The fix covers two sites,
summedand_step, so each block has the same one-line comment. - Judgement call:
np.spacingstays inside theerrstateblock withnextafter, because the brief treats:82-85and:164-165as one step computation each.np.spacingalone didn't warn. - Judgement call:
months_summedin invariant 8. I added one sentence naming its existing exhaustive test, because the body now names the functions as they are. - Judgement call: shard durations. I added no lines to
shard_durations.tomlfor the two new tests, which are fast;code_healthpasses. - Title unchanged. The
Reproducer:andaxiom:lines are unchanged.
Generated by Claude Code
|
Ready for hub review Verdict: APPROVE WITH NITS at I reviewed All three P2s from the last round are closed, and four of the five P3s are closed. P3-2 is mostly closed: one comment in the taper still says the excess is whole pence without the qualifier. The underflow fix changes no value. Both new tests fail against P1None. P2None. P3P3-1. The body says the mutants ran "at this head", but they ran at
P3-2. A comment in the taper still says the excess is whole pence without the qualifier. (
P3-3.
P3-4. The voice pass made a regulation act in two places. (body, lines 22 and 101)
Checked and soundPrevious round, one by one
The underflow fix
The body
Local runs at the head
CI at the head (
What I couldn't check
Generated by Claude Code |
The excess comes in whole pence only for a limit in whole pence. numpy reports both nextafter from zero and spacing of the subnormal it returns as underflow, so both stay inside the errstate blocks. Text only; the AST with docstrings blanked matches df78930 in both files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS arm64 numpy reports np.spacing(np.float32(0)) as underflow, at the locked 2.4.6 and at 2.1.3, 2.4.2 and 2.5.4; CI's Linux arm64 runners pass the same check at 2.4.6. Under the strict state in test_stored_pence.py, stored_pence of a zero amount then raises on a Mac, so both zero tests fail there. The Class 1 tolerance in carers_allowance_earnings takes the same step for a person with no NI income. Both now work it out under np.errstate(under="ignore"), which changes no value and restores numpy's error state when the block ends. At macOS arm64 and numpy 2.4.6, test_stored_pence.py gives 2 failed on the two zero tests before this commit and 11 passed after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Case 25 of carers_allowance_earnings_limit.yaml pins this review's example: £14,000.01 of profit and a £6,039.38 premium give earnings of £10,608.50 and the full £4,495.40 award. carers_allowance_earnings now applies reg 5 to each deduction and no longer rounds the stored total, and TheAxiomFoundation/rulespec-uk#482 carries the same case.
Fixes #2541
Reproducer:
policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py::test_a_penny_a_week_over_each_limit_is_over_it_in_every_year,policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py::test_pay_a_penny_a_week_over_each_limit_ends_entitlement,policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py::test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny,policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py::test_a_pension_total_entered_directly_counts_like_a_programme,policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml,policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml,policyengine_uk/tests/test_carers_allowance_weekly_earnings_above_limit_properties.py::test_a_penny_two_pence_and_a_pound_over_each_limit_taper_exactlyaxiom: TheAxiomFoundation/rulespec-uk#482 queued
What and why
carers_allowance_weekly_earnings_above_limitgives how far a carer's weekly earnings exceed the Carer's Allowance earnings limit, andis_gainfully_employed_for_carers_allowanceasks whether they exceed it at all. Together they decide whether a carer's earnings exceed the Carer's Allowance earnings limit: SSCBA 1992 s.70(1)(b), and ICA Regulations 1976 reg 8(1), "have exceeded £204" from 6 April 2026. In the Computation of Earnings Regulations 1996:On main,
carers_allowance_weekly_earnings_above_limitfloors the weekly pence in float32, the dtype ofcarers_allowance_earnings:np.floor(weekly * 100 + 1e-6) / 100, less the limit. At these magnitudes the+ 1e-6does nothing, because float32 values near 20,400 lie 0.00195 apart. So float32 rounding, not the law, decided cases within about 0.002 pence a week of a boundary. Earnings of 52 × L + £0.52 a year (L + £0.01 a week, a penny over) counted as within the limit for L = £128, £132, £139 and £151. A carer a penny a week over the 2020-21 to 2024-25 limits got the full allowance.The rule
The model now applies reg 5 where each calculation happens, so net earnings come out in whole pence, which float32 can hold recoverably:
carers_allowance_earningsapplies reg 5 to each step. It works in float64 pence from the whole pence a user entered for each input, whichpolicyengine_uk/utils/pence.py:stored_pencerecovers.carers_allowance_earningsrounds the model's figures for them up the same way, in the claimant's favour.carers_allowance_earningsredoesni_class_1_employee's formula exactly on the year's NI income in whole pence. The model works out each month's Class 1 in float32 and sums the twelve. That can hold a whole-penny total further out thanstored_pencerecovers: float32 stores £175.56 (8% of £14,764.46 less £12,569.96) as 175.5600128, and rounding that up would deduct a penny too much. The formula uses the exact figure where it agrees with the stored total to within float32 error (one float32 step of the NI income plus 64 of the contribution), and otherwise sums the stored months exactly. Where a user supplies Class 1 as an input, for the year or month by month, or a reform calculates it, the formula deducts that amount, summing its months exactly.pension_contributions, whether a user enters it as a total or the model sums it from its programmes. Where the model sums them,amount_penceredoes the float32 sum in float64 pence. It reads each programme withstoredand adds their pence withsummed, which keeps that sum where the stored total lies within float32 rounding of it and otherwise reads the total as entered. The model holds each programme as one annual amount, not the payments reg 10(4)(b) halves one by one, so the formula halves the year's total and rounds up once.carers_allowance_weekly_earnings_above_limitreads the whole pence back exactly, divides by 52 and disregards the fraction (reg 8(1)(b)(iii) for pay; reg 11(1)(a), average weekly earnings over a year, for profit; with reg 5), and returns the excess over the limit, or zero at or below it (reg 8(1), strict). Every legislated and projected limit comes in whole pence, and against such a limit the excess comes out in whole pence too. Where a user enters an amount directly with a fraction of a penny, the variable disregards the fraction. It compares the limit in pence, rounded to a millionth of a penny, so it reads a reformed limit of £204.10 as 20,410 pence, not 20,409.999…is_gainfully_employed_for_carers_allowancekeeps main's form: any excess counts the carer as gainfully employed. Over a limit in whole pence, an excess comes to at least a penny, which float32 stores as a positive amount, so no excess reads as exactly zero. Under a reformed limit with a fraction of a penny, such as £204.105, the excess can come to half a penny, still a positive amount.carers_allowance_weekly_by_part_of_yearinpolicyengine_uk/utils/carers_allowance_taper.py) takestaper.rate(100%) of the excess off the weekly rate, in England and Wales, from 6 October 2030 in the model. It reads the excess withstored_pence, so it takes off exactly the whole pence above the limit (float32 stores 1p as 0.0099999998), and becausestored_pencereturns float64 the formula subtracts the excess from the rate in float64. Without that read the subtraction runs in float32, and seven taper tests fail (M7 below): a carer a penny a week over the 2021-22 limit gets £3,514.6796875 a year rather than £3,514.68.stored_pence(x)returns the whole pence a float32 lies within half a float32 step of, and otherwise its exact value in pence. When a user enters an amount in whole pence, float32 stores it within half a step of them; below £131,072 a step falls short of a penny, so no other whole number of pence lies that close. Each step of the calculation runs exactly in float64 (a float32 has 24 significant bits, so multiplying it by 100 loses nothing).Why not round the stored total. This PR's first version took the nearest penny of the total and its second the nearest half penny. The first counted 52L + £0.515 (pay less half an odd-pence contribution) up. The second counted genuine fractions up: £14,000.01 of profit and a £6,039.38 premium leave £10,608.5174 unrounded, which it took to £10,608.52 (£204.01 a week), removing the full £4,495.40. A tolerance below the total can't fix this. Float32 holds a £14k–£20k profit only to within about a tenth of a penny before the model takes tax off, so whole-pence and fractional totals overlap. The model decides them exactly by applying reg 5 to each step before it stores the result.
Known limits:
stored_pencereads an input within half a float32 step of a whole penny as that penny: float32 stores £10,608.5195 as the same number as £10,608.52.stored_pencemay not recover whole pence exactly. That affects the decision only for limits above £2,520 a week, and no statutory limit exceeds £204.The taper reads exact pence
With the taper, the amount above the limit matters as well as whether there is one. Main's formula subtracts the limit from a float32 weekly amount, so its excess goes wrong even where its sign comes out right. On main (46a6067), a carer 1p a week over the limit has an excess of 1.0002136p in 2015-16 to 2019-20, 0p (not 1p) in 2020-21 to 2024-25, which #2541 describes, and 0.99945068p (not 1p) in each year from 2025-26 to 2031-32. Main's taper therefore takes a penny a week too little off some awards: for each whole number of pence from 0p to £100 a week over the 2031-32 limit (10,001 carers), main pays 1,700 of them £0.52 a year (a penny a week) more than the rate less the excess, while this head pays each of them that amount to within a float32 step of the annual award. At this head the excess comes to exactly 1p, 2p and 100p for a carer 1p, 2p and £1 a week over the limit, in every year from 2020-21 to 2041-42, and the tapered award comes to the rate less that, to within a float32 step of the annual award.
Because the model's taper pays a carer over the limit from 2030-31,
test_a_penny_a_week_over_each_limit_is_over_it_in_every_yearnow switches the taper off and tests the cliff edge, while the new module tests the same steps with the taper on.Effect (carer in England, aged 45, caring 40 hours,
carers_allowance_earningsentered; before: main at 46a6067)From 2030-31 the model's taper pays a carer a penny a week over the limit in England the rate less a penny (invariant 11). In 2030-31 the taper applies for 182 of the year's 365 days, so the award a penny over £228 comes to £2,500.44 for the year; from 2031-32 it comes to the rate less a penny for the whole year (£5,114.90 a penny over £234). Main gives the same two awards, because a penny a week over these limits its excess of 0.99945068p moves the annual award by less than a float32 step. Invariant 11 tests the rate less the excess with the taper applied for the whole of every year from 2020-21.
52 × L and 52 × L + £0.51 stay within the limit in every year, before and after.
carers_allowance_earningsitself moves by at most a few pence a year (each deduction's fraction of a penny).For employees whose Class 1 comes to a whole number of pence that the float32 sum holds out, this head judges earnings from the whole pence the law gives, as for £14,764.46 of pay with a £7,960.76 contribution in 2026-27 (YAML Cases 26–28). Each of the three earns exactly a penny a week over the limit, and this head judges all three over. Main also judges all three over, from its unrounded float32 earnings (£10,608.5205, £10,192.5205 and £7,852.5254).
Pension contributions entered as a total (
pension_contributions: 1000) come off earnings as on main (YAML Case 29).Two variables read
carers_allowance_earnings:is_gainfully_employed_for_carers_allowance, throughcarers_allowance_weekly_earnings_above_limit;carer_support_payment_pre_couple_limit. Its own test,earnings < (limit + £0.01) × 52in float32, decides whole pence exactly.CSP's SSI 2023/302 Schedule 2 has reg 5's rounding (para 4) and the same net earnings and profit (paras 9 and 12), so this head makes its boundary exact too. In Scotland in 2026-27, £12,576.76 of profit with a £3,932.96 premium has earnings of £10,608.51 (£204.00 a week) and CSP of £4,495.40; main has £10,608.5225 and CSP nil. A penny more profit takes earnings over the limit on both.
Invariants
Stated in the test module docstrings, and executed:
The model judges whole pence exactly: annual earnings of A pence count as over the limit L exactly when A // 52 > 100 × L, in every year from 2015 to the parameter horizon.
test_whole_pence_are_judged_exactlytests this (Hypothesis, with boundary examples for every Carer's Allowance: a penny a week over the 2020-21 to 2024-25 earnings limits counts as within the limit #2541 limit), and so do the per-limit tests.The model judges pay less half a pension contribution as the law says: pay of P pence with a contribution of C pence counts as over the limit exactly when (P − ceil(C/2)) // 52 > 100 × L. Three checks test this:
test_pay_less_half_a_pension_contribution_is_judged_as_the_law_says(Hypothesis),test_half_an_odd_pence_pension_contribution_counts_as_a_penny_off(which also checks that earnings come to exactly P − ceil(C/2)), and YAML Cases 23 and 24.Each deduction counts a fraction of a penny as a penny: with tax and Class 1 or Class 4 to pay, net earnings come to exactly P less each deduction rounded up, and the limit test follows them. The test works Class 1 out exactly as 8% of P less 52 × £241.73 instead of reading it from the model.
test_each_deduction_counts_a_fraction_of_a_penny_as_a_pennytests this (trading and employee, 2024-25 and 2026-27, with eight known boundary pairs), and so do YAML Cases 25–28.Exact arithmetic (differential): for any stored amount, the float64 formula agrees with
_over_limit, which restates it infractions.Fraction.test_the_formula_matches_exact_arithmetic_and_rises_with_earningstests this, with the float32 values either side of 52L + £0.52 and YAML Case 25's earnings under a nearest-half-penny rule as explicit examples, and so dotest_bounds_and_differentialand the pension-share property test.Monotonic in earnings: more earnings never take a person from over the limit to within it.
Monotonic in steps of 2p: raising pay or profit by 2p or more never lowers net earnings.
test_net_earnings_never_fall_for_a_rise_of_two_pence_or_moreruns every penny across £12,570, £50,270 and £125,140 (pay) and £12,570 and £50,270 (profit) and finds no fall at 2p or more. It also checks that each 1p fall comes to exactly 1p.test_a_penny_more_profit_can_lower_net_earnings_by_a_pennyand YAML Cases 30–31 pin the intended violation.Entered pension totals:
pension_contributionsentered as a total comes off earnings exactly as the same amount entered as a programme does.test_a_pension_total_entered_directly_counts_like_a_programmeand YAML Case 29 test this.stored_pencehas four properties, whichtest_stored_pence.pytests:summed, whichamount_penceuses for an amount the model adds, recovers a million float32 sums of whole-pence amounts below £131,072, of either sign, in groups of up to five summed and then added, asaddsdoes (some of whichstored_pencealone misses). It reads any other total as entered, which YAML Case 32 also tests.months_summed, whichamount_penceuses for a monthly flow over a year, recovers every whole-pence amount below £32,768 that a user enters for a year and policyengine-core divides among its months (an exhaustive test).Nothing underflows at zero. When numpy warns on every floating-point error and Python raises every warning as an error,
stored_pence,summedandmonths_summedread zero amounts as 0 pence, with a bound of zero, and raise nothing. Under that state,summedandmonths_summedgive the samePenceas under numpy's default, for float32 amounts in whole pence from −£131,072 to £131,072. Above a zero bound,np.nextafterreturns the smallest subnormal float32, and numpy reports both that call andnp.spacingof that step as underflow, sosummedand_stepwork it out undernp.errstate(under="ignore"), which restores numpy's error state when the block ends. On macOS arm64 numpy also reportsnp.spacingof zero as underflow, sostored_penceand the Class 1 tolerance incarers_allowance_earningstake their steps under the same block.test_zero_amounts_read_as_zero_pence_without_a_warningand the Hypothesis propertytest_zero_bounds_read_the_same_under_a_strict_error_statetest this, and so doestest_the_divisions_the_filters_hid_raise_no_runtime_warningintest_warning_filters.py, which runs the model under that state.Strict at the limit: earnings of exactly 52 × L a year stay within it, in every year.
The existing invariants (bounds, unearned income kept out of earnings, the pension-share exactness, within a penny of reg 5 rounding) still hold.
An exact excess: annual earnings of A whole pence against a limit L have an excess of max(0, A // 52 − 100 × L) pence a week, which the model recovers exactly from float32, and it counts the carer as gainfully employed exactly when the excess exceeds zero. With a reform applying the taper in every year from 2020-21, so that it reads every limit since then:
carers_allowance_pre_couple_limit, the award before the couple and overlap restrictions;carers_allowance_pre_couple_limit, from which the model buildscarers_allowance_pre_overlap(carers_allowancefloors its own result at zero again, so only this check catches a taper that goes negative);Two tests check these:
test_a_penny_two_pence_and_a_pound_over_each_limit_taper_exactly(each year from 2020 to 2041, at 0p, 1p, 2p and £1 a week over, each also with 51p a year more, which reg 5 disregards) andtest_the_taper_follows_exact_pence(Hypothesis, 40 whole-pence amounts per example near the limit, near where the taper leaves nothing, and anywhere below £131,072, with the four Carer's Allowance: a penny a week over the 2020-21 to 2024-25 earnings limits counts as within the limit #2541 limits as explicit examples).Tests
test_carers_allowance_weekly_earnings_above_limit_properties.py, invariant 11.test_a_penny_a_week_over_each_limit_is_over_it_in_every_yearenterscarers_allowance_earningsat 52L, 52L + £0.51 and 52L + £0.52. After the last legislated limit (2026-27) it takes the limit from the model, so it covers the model's projected limits from 2027-28 (£212, rising to £309 from 2039-40).test_pay_a_penny_a_week_over_each_limit_ends_entitlemententers pay, from 2015 to 2026; it stops at 2026 because from 2029employment_incomesubtractssalary_sacrifice_broad_base_haircut.test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny, as above: 1,210 trading and employee pairs in 2024-25 and 2026-27 whose net earnings, deductions unrounded, lie from 4p below to 2p above the threshold. It adds YAML Case 25's trader and seven employee pairs whose Class 1 comes to a whole number of pence that the float32 sum holds more than half a step out, and it asserts that each one's earnings exceed the limit by exactly a penny a week.test_class_1_supplied_as_an_input_is_deducted_as_supplied,test_class_1_entered_in_whole_pence_is_deducted_in_whole_pence,test_class_1_under_a_reform_of_its_formula_is_the_amount_calculatedand their neighbours).test_a_limit_in_pounds_and_pence_is_read_exactly: a reformed limit of £204.10.test_stored_pence.py(invariant 8). Withsummedand_stepas they stood at 32093c6, withoutnp.errstate(under="ignore"), both fail withRuntimeWarning: underflow encountered in nextafter.Each wrong rule in the excess and the taper fails a test. Each mutation below changes the formula at 32093c6, with everything else unchanged; df78930 changes no value the excess or the taper reads, and M3 still fails at df78930. The mutants ran against the new taper module and the two every-limit tests (
test_a_penny_a_week_over_each_limit_is_over_it_in_every_yearandtest_pay_a_penny_a_week_over_each_limit_ends_entitlement), 25 tests:carers_allowance_weekly_earnings_above_limitnp.float32(weekly_pence / 100) - np.float32(limit)np.maximum(0, …)removed)test_the_taper_follows_exact_pence, atpre_couple >= 0stored_pencetest_the_taper_follows_exact_pence)>=)Runs at 71704cd
26052cb changes only comments in
carers_allowance_taper.pyandpence.py: the AST of each, with docstrings blanked, matches df78930's. 71704cd takesnp.spacingundernp.errstate(under="ignore")instored_penceand in the Class 1 tolerance ofcarers_allowance_earnings, which changes no value:stored_pencematches df78930's on 400,010 float32 values.On macOS arm64 at numpy 2.4.6, which
uv.lockpins,test_stored_pence.pygives 2 failed againstpence.pyat df78930 (both zero tests, withRuntimeWarning: underflow encountered in spacing) and 11 passed againstpence.pyat 71704cd. We ran that file on its own with the module loaded from its path. CI's Linux arm64 runners passed both zero tests at df78930. Not run on macOS:test_warning_filters.py, which needs the whole package.Local runs at df78930
Python 3.13,
uv sync --frozen --extra dev,-n 4:pytestontest_warning_filters.py: 32 passed (at 32093c6 both cases oftest_the_divisions_the_filters_hid_raise_no_runtime_warningfail withRuntimeWarning: underflow encountered in nextafter; on main at 6c6e229 and at 0d100df both pass).pytestontest_carers_allowance_earnings_limit_properties.py(59),test_carers_allowance_pension_contribution_deduction_rate_properties.py(2),test_carers_allowance_earnings_taper.py(68),test_carers_allowance_earnings_limit_projection.py(8),test_carers_allowance_weekly_earnings_above_limit_properties.py(23),test_carer_support_payment.py(10) andtest_stored_pence.py(11), each file on its own: 181 passed.policyengine-core testoncarers_allowance_earnings_limit.yaml: 37 passed; oncarer_support_payment_earnings_limit.yaml: 19 passed; on the 22 YAML files whose names matchcarerorcare_hours: 308 passed; on the 72 that mention them: 1,318 passed.pytest policyengine_uk/tests/code_health: 3,088 passed.format --check .(2,167 files),check .,check --select F811,F821 policyengine_uk, andgit diff --check 32093c6cf..HEAD: clean..github/check_fix_pr.pyon this title and body, with the changes from its merge base with main: PASS (6 changed executable tests).git merge-tree --write-tree), as does 6c6e229. On that merge, not committed,test_warning_filters.pyandtest_stored_pence.pygive 43 passed.Local runs at 32093c6
Python 3.13,
uv sync --frozen --extra dev,-n 4:pytestontest_carers_allowance_earnings_limit_properties.py,test_carers_allowance_pension_contribution_deduction_rate_properties.py,test_carers_allowance_earnings_taper.py(the one module on main that readscarers_allowance_weekly_earnings_above_limit),test_carers_allowance_earnings_limit_projection.py,test_carers_allowance_weekly_earnings_above_limit_properties.py,test_carer_support_payment.pyandtest_stored_pence.py: 179 passed.policyengine-core teston the 22 YAML files underpolicyengine_uk/tests/policywhose names matchcarerorcare_hours: 308 passed; on the 72 that mentioncarerorcare_hours(a superset): 1,318 passed.pytest policyengine_uk/tests/code_health: 3,088 passed.format --check .,check .,check --select F811,F821 policyengine_uk, andgit diff --check: clean..github/check_fix_pr.pyon this title and body: PASS (6 changed executable tests).git merge-tree --write-tree). On that merge, not committed, the same pytest files give 179 passed, the YAML 308 and 1,318 passed, andcode_health3,090 passed.Aggregate impact
CI's Aggregate impact report (run 41535559314) compares main at
0080274d78withe1e1b1d13f, CI's merge of 71704cd into main, onenhanced_frs_2024_25.h5(policyengine-uk-data 1.58.1), fiscal years 2025-26 to 2031-32, a full microsimulation for each side and year. It passes, with 0 checks broken, 0 fixed, 1 failing on both sides and no move flagged.No figure moves from 2025-26 to 2029-30. No record in the survey data lies close enough to a limit to change, so the fix matters for individual carers a penny a week over it. In the taper years every move stays below £1,000: Carer's Allowance spending falls £512 in 2030-31 and rises £6 in 2031-32, Universal Credit spending rises £306 in 2030-31, and household net income falls £184 in 2030-31 and rises £7 in 2031-32.
🤖 Generated with Claude Code