Skip to content

Count Carer's Allowance earnings-limit pence exactly - #2543

Open
MaxGhenis wants to merge 25 commits into
mainfrom
fix/2541-ca-earnings-pence
Open

MaxGhenis wants to merge 25 commits into
mainfrom
fix/2541-ca-earnings-pence

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

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_exactly
axiom: TheAxiomFoundation/rulespec-uk#482 queued

What and why

carers_allowance_weekly_earnings_above_limit gives how far a carer's weekly earnings exceed the Carer's Allowance earnings limit, and is_gainfully_employed_for_carers_allowance asks 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:

  • Where "any calculation under these Regulations results in a fraction of a penny", DWP treats the fraction as "a penny" "if it would be to the claimant's advantage", and "otherwise it shall be disregarded" (reg 5).
  • DWP turns a yearly payment into a weekly one "by dividing the amount of the payment by 52" (reg 8(1)(b)(iii)).
  • DWP nets PAYE, primary Class 1 and "one half of any sum paid … by way of a contribution towards an occupational or personal pension scheme" off pay (reg 10(4)), and a notional basic-rate income tax, Class 4 (and, before 2024-25, Class 2) and half of pension premiums off trading profit (regs 13 and 14).

On main, carers_allowance_weekly_earnings_above_limit floors the weekly pence in float32, the dtype of carers_allowance_earnings: np.floor(weekly * 100 + 1e-6) / 100, less the limit. At these magnitudes the + 1e-6 does 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:

  1. carers_allowance_earnings applies reg 5 to each step. It works in float64 pence from the whole pence a user entered for each input, which policyengine_uk/utils/pence.py:stored_pence recovers.
    • It takes pay and profit down to a whole penny (a higher figure never favours the claimant).
    • It takes the calculated deductions up to a whole penny, as reg 5 requires: notional tax, Class 4, Class 2, and half of pension contributions. That turns the half penny of an odd-pence contribution into a penny off earnings.
    • For PAYE and Class 1, the formula deducts the amounts actually taken from the pay (reg 10(4)(a)), which come in whole pence. carers_allowance_earnings rounds the model's figures for them up the same way, in the claimant's favour.
    • Where the model calculates Class 1 with its own formula, carers_allowance_earnings redoes ni_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 than stored_pence recovers: 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.
    • For pension contributions, the formula reads pension_contributions, whether a user enters it as a total or the model sums it from its programmes. Where the model sums them, amount_pence redoes the float32 sum in float64 pence. It reads each programme with stored and adds their pence with summed, 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.
  2. carers_allowance_weekly_earnings_above_limit reads 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…
  3. is_gainfully_employed_for_carers_allowance keeps 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.
  4. The Budget 2026 earnings taper (carers_allowance_weekly_by_part_of_year in policyengine_uk/utils/carers_allowance_taper.py) takes taper.rate (100%) of the excess off the weekly rate, in England and Wales, from 6 October 2030 in the model. It reads the excess with stored_pence, so it takes off exactly the whole pence above the limit (float32 stores 1p as 0.0099999998), and because stored_pence returns 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.
# carers_allowance_weekly_earnings_above_limit
annual_pence = np.floor(stored_pence(earnings))
weekly_pence = np.floor(annual_pence / WEEKS_IN_YEAR)
limit_pence = np.round(limit * 100, 6)
return max_(0, weekly_pence - limit_pence) / 100

# is_gainfully_employed_for_carers_allowance
return person("carers_allowance_weekly_earnings_above_limit", period) > 0

# the taper
excess = stored_pence(person("carers_allowance_weekly_earnings_above_limit", period)) / 100
tapered = np.isin(country, list(taper.countries)) * np.maximum(0, ca.rate - taper.rate * excess)

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:

  • Inputs float32 can't tell apart. stored_pence reads 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.
  • Class 1 falls back to the stored months when the exact formula disagrees with them beyond float32 error. A whole-penny amount the float32 sum holds more than half a step out then counts as a penny more.
  • The model reads an entered pension total within float32 rounding of its programmes' sum (under 0.2p for a total of £10,000) as that sum.
  • The formula halves the year's total of pension contributions and rounds up once. It reads "one half of any sum paid … in respect of a pay period" this way because the model holds no payments.
  • The model keeps its own approximations. It takes PAYE as the tax on the pay alone on a standard code, as the variable's documentation says. The model's annual Class 1 primary threshold works out at £12,569.96 (pay of £12,601.00 gives Class 1 of £2.4832). This PR takes Class 1 as the model's formula gives it.
  • Above £131,072 a year, stored_pence may 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_year now 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_earnings entered; before: main at 46a6067)

Year Limit Earnings Weekly Before After
2015–2019 £110, £116, £120, £123 52L + 0.52 L + £0.01 gainfully employed, CA nil unchanged
2020 £128 £6,656.52 £128.01 not gainfully employed, CA £3,497.00 gainfully employed, CA nil
2021 £128 £6,656.52 £128.01 not, CA £3,515.20 gainfully employed, CA nil
2022 £132 £6,864.52 £132.01 not, CA £3,624.40 gainfully employed, CA nil
2023 £139 £7,228.52 £139.01 not, CA £3,991.00 gainfully employed, CA nil
2024 £151 £7,852.52 £151.01 not, CA £4,258.80 gainfully employed, CA nil
2025–2026 £196, £204 52L + 0.52 L + £0.01 gainfully employed, CA nil unchanged
2027–2029 £212, £217, £222 (projected) 52L + 0.52 (entered as earnings) L + £0.01 gainfully employed, CA nil unchanged

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_earnings itself 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, through carers_allowance_weekly_earnings_above_limit;
  • for the Scottish Carer Support Payment (2025-26 on), carer_support_payment_pre_couple_limit. Its own test, earnings < (limit + £0.01) × 52 in 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:

  1. 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_exactly tests 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.

  2. 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.

  3. 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_penny tests this (trading and employee, 2024-25 and 2026-27, with eight known boundary pairs), and so do YAML Cases 25–28.

  4. Exact arithmetic (differential): for any stored amount, the float64 formula agrees with _over_limit, which restates it in fractions.Fraction. test_the_formula_matches_exact_arithmetic_and_rises_with_earnings tests 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 do test_bounds_and_differential and the pension-share property test.

  5. Monotonic in earnings: more earnings never take a person from over the limit to within it.

  6. Monotonic in steps of 2p: raising pay or profit by 2p or more never lowers net earnings.

    • Why: the deductions that rise with pay (PAYE, Class 1) or with profit (notional tax, Class 4) have marginal rates adding to at most a half in 2026-27, and the model rounds each up. So a rise of R pence adds less than R/2 + 2 pence of deductions, at most R when R ≥ 2.
    • Intended violation: a 1p rise can lower net earnings by 1p, when it takes both deductions to their next penny at once. £12,570.50 → £12,570.51 of profit with a £3,923.70 premium takes earnings from £10,608.52 (over) to £10,608.51 (within). The formula produces that fall by rounding each deduction up, as reg 5 requires.
    • test_net_earnings_never_fall_for_a_rise_of_two_pence_or_more runs 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_penny and YAML Cases 30–31 pin the intended violation.
  7. Entered pension totals: pension_contributions entered 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_programme and YAML Case 29 test this.

  8. stored_pence has four properties, which test_stored_pence.py tests:

    • every whole-pence amount below £131,072 round-trips exactly (exhaustive);
    • the result never lies more than half a float32 step from the stored amount;
    • it keeps a fraction more than half a step from a whole penny exactly;
    • it never gives fewer pence for a larger amount.

    summed, which amount_pence uses 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, as adds does (some of which stored_pence alone misses). It reads any other total as entered, which YAML Case 32 also tests. months_summed, which amount_pence uses 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, summed and months_summed read zero amounts as 0 pence, with a bound of zero, and raise nothing. Under that state, summed and months_summed give the same Pence as under numpy's default, for float32 amounts in whole pence from −£131,072 to £131,072. Above a zero bound, np.nextafter returns the smallest subnormal float32, and numpy reports both that call and np.spacing of that step as underflow, so summed and _step work it out under np.errstate(under="ignore"), which restores numpy's error state when the block ends. On macOS arm64 numpy also reports np.spacing of zero as underflow, so stored_pence and the Class 1 tolerance in carers_allowance_earnings take their steps under the same block. test_zero_amounts_read_as_zero_pence_without_a_warning and the Hypothesis property test_zero_bounds_read_the_same_under_a_strict_error_state test this, and so does test_the_divisions_the_filters_hid_raise_no_runtime_warning in test_warning_filters.py, which runs the model under that state.

  9. Strict at the limit: earnings of exactly 52 × L a year stay within it, in every year.

  10. The existing invariants (bounds, unearned income kept out of earnings, the pension-share exactness, within a penny of reg 5 rounding) still hold.

  11. 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:

    • the award comes to the rate less the excess, 52 × max(0, 100 × R − E) pence a year, to within a float32 step of the annual award (after the last legislated rate the model uprates the rates, so they don't come in whole pence);
    • it never pays more than the full rate, and neither does carers_allowance_pre_couple_limit, the award before the couple and overlap restrictions;
    • it never goes negative, and neither does carers_allowance_pre_couple_limit, from which the model builds carers_allowance_pre_overlap (carers_allowance floors its own result at zero again, so only this check catches a taper that goes negative);
    • it falls by at most £1 for each £1 above the limit: between any two carers, the weekly award differs by no more than their excesses;
    • it equals the full rate at or under the limit.

    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) and test_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

  • The taper at every limit (2020 to 2041): test_carers_allowance_weekly_earnings_above_limit_properties.py, invariant 11.
  • Every limit, in every year (2015 to 2041), with the taper switched off: test_a_penny_a_week_over_each_limit_is_over_it_in_every_year enters carers_allowance_earnings at 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_entitlement enters pay, from 2015 to 2026; it stops at 2026 because from 2029 employment_income subtracts salary_sacrifice_broad_base_haircut.
  • Odd-pence pension contributions at every limit, 2015 to 2026, plus the Hypothesis property above.
  • Fractions of a penny in each deduction: 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.
  • Monotonic net earnings at every penny across each threshold, the 1p exception, and entered pension totals, as above.
  • Class 1 supplied or reformed: the formula deducts Class 1 as a user enters it (for the year or month by month) or as a reform calculates it, summing its months exactly (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_calculated and their neighbours).
  • test_a_limit_in_pounds_and_pence_is_read_exactly: a reformed limit of £204.10.
  • YAML Cases 21–37, with expected values from the law: reg 8(1)'s £151 from 8 April 2024, £196 from 7 April 2025 and £204 from 6 April 2026; regs 5, 10(4) and 14; Class 1 at 8% above 52 × £241.73; and the CA rates of £81.90 and £86.45.
    • 21–24: 2024-25 pay of £7,852.51 (within) and £7,852.52 (over), and £7,852.52 and £7,852.53 each with a 1p contribution (earnings £7,852.51, within; £7,852.52, over).
    • 25: 2026-27 self-employed, £14,000.01 profit and a £6,039.38 premium (earnings £10,608.50, within, CA £4,495.40).
    • 26–28: employees with a whole-penny Class 1 (£175.56 in 2026-27 and 2025-26, £43.80 in 2024-25), each a penny a week over.
    • 29: a £1,000 pension total entered directly (within).
    • 30–31: the 1p pair.
    • 32: two pension programmes whose float32 sum lands 0.0137p out, more than half a step (over).
    • 33–37: the formula deducts Class 1 as entered, both as nil and for the year in whole pence, and reads in whole pence a pension total, pay, and both, where the model sums each from two inputs.
  • CSP YAML: two cases at the per-step boundary in Scotland, 2026-27 (£12,576.76 within, CSP £4,495.40; £12,576.77 over, CSP nil).
  • Zero amounts under a strict numpy error state: two tests in test_stored_pence.py (invariant 8). With summed and _step as they stood at 32093c6, without np.errstate(under="ignore"), both fail with RuntimeWarning: 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_year and test_pay_a_penny_a_week_over_each_limit_ends_entitlement), 25 tests:

Mutation Result
M1: main's float32 carers_allowance_weekly_earnings_above_limit killed, 25 failed (all 23 taper tests and both every-limit tests)
M2: excess as np.float32(weekly_pence / 100) - np.float32(limit) killed, 23 failed
M3: the taper not floored at zero (np.maximum(0, …) removed) killed, 1 failed: test_the_taper_follows_exact_pence, at pre_couple >= 0
M4: the taper takes 101% of the excess killed, 23 failed
M5: weekly pence not floored (reg 5's fraction kept) killed, 25 failed
M6: the taper reads the excess in whole pounds killed, 23 failed
M7: the taper reads the float32 excess without stored_pence killed, 7 failed (2021, 2022, 2025, 2039, 2040 and 2041 of the per-year test, and test_the_taper_follows_exact_pence)
M8: the excess counted from the limit (+1p, so >=) killed, 25 failed

Runs at 71704cd

26052cb changes only comments in carers_allowance_taper.py and pence.py: the AST of each, with docstrings blanked, matches df78930's. 71704cd takes np.spacing under np.errstate(under="ignore") in stored_pence and in the Class 1 tolerance of carers_allowance_earnings, which changes no value: stored_pence matches df78930's on 400,010 float32 values.

On macOS arm64 at numpy 2.4.6, which uv.lock pins, test_stored_pence.py gives 2 failed against pence.py at df78930 (both zero tests, with RuntimeWarning: underflow encountered in spacing) and 11 passed against pence.py at 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:

  • pytest on test_warning_filters.py: 32 passed (at 32093c6 both cases of test_the_divisions_the_filters_hid_raise_no_runtime_warning fail with RuntimeWarning: underflow encountered in nextafter; on main at 6c6e229 and at 0d100df both pass).
  • pytest on test_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) and test_stored_pence.py (11), each file on its own: 181 passed.
  • policyengine-core test on carers_allowance_earnings_limit.yaml: 37 passed; on carer_support_payment_earnings_limit.yaml: 19 passed; on the 22 YAML files whose names match carer or care_hours: 308 passed; on the 72 that mention them: 1,318 passed.
  • pytest policyengine_uk/tests/code_health: 3,088 passed.
  • Ruff 0.17.2 format --check . (2,167 files), check ., check --select F811,F821 policyengine_uk, and git diff --check 32093c6cf..HEAD: clean.
  • .github/check_fix_pr.py on this title and body, with the changes from its merge base with main: PASS (6 changed executable tests).
  • Main at 0080274 merges cleanly (git merge-tree --write-tree), as does 6c6e229. On that merge, not committed, test_warning_filters.py and test_stored_pence.py give 43 passed.

Local runs at 32093c6

Python 3.13, uv sync --frozen --extra dev, -n 4:

  • pytest on test_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 reads carers_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.py and test_stored_pence.py: 179 passed.
  • policyengine-core test on the 22 YAML files under policyengine_uk/tests/policy whose names match carer or care_hours: 308 passed; on the 72 that mention carer or care_hours (a superset): 1,318 passed.
  • pytest policyengine_uk/tests/code_health: 3,088 passed.
  • Ruff 0.17.2 format --check ., check ., check --select F811,F821 policyengine_uk, and git diff --check: clean.
  • .github/check_fix_pr.py on this title and body: PASS (6 changed executable tests).
  • Main at 46a6067 merges cleanly (git merge-tree --write-tree). On that merge, not committed, the same pytest files give 179 passed, the YAML 308 and 1,318 passed, and code_health 3,090 passed.

Aggregate impact

CI's Aggregate impact report (run 41535559314) compares main at 0080274d78 with e1e1b1d13f, CI's merge of 71704cd into main, on enhanced_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

MaxGhenis and others added 3 commits October 20, 2026 17:01
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>
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Aggregate impact on certified data: pass

Base 0080274d78 against head e1e1b1d13f, both on enhanced_frs_2024_25.h5 (policyengine-uk-data 1.58.1, sha256 056e80fd8e91), fiscal years 2025-26 to 2031-32. Each year is a full microsimulation run on each side.

Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in report_spec.yaml; a flag does not fail the job).

Workflow run and full report

Notes

  • In the years a figure from fewer than 10 records is suppressed (spending.disabled_students_allowance), every total containing it, and the decile incomes, are rebuilt from their other components, so no published total includes it. Those totals are marked †. A change is withheld where the two sides leave out different figures and can't be lined up.

Moves flagged for review

None.

Receipts by tax head

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Income tax 0 0 0 0 0 -£19 -£0

Spending by benefit and service

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Pension Credit 0 0 0 0 0 +£3 0
Universal Credit 0 0 0 0 0 +£306 0
Carer's Allowance 0 0 0 0 0 -£512 +£6
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed

Caseloads

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed

Government balance, each component counted once

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Government receipts, each component once 0 0 0 0 0 -£19 -£0
Government spending 0 † 0 † 0 † 0 † 0 † -£203 † +£6 †
Government balance, each receipt once 0 † 0 † 0 † 0 † 0 † +£184 † -£7 †

Model totals

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
gov_tax 0 0 0 0 0 -£23 0
household_tax 0 0 0 0 0 -£16 0
pre_budget_change_household_tax 0 0 0 0 0 -£17 0
gov_spending 0 † 0 † 0 † 0 † 0 † -£203 † +£6 †
household_benefits 0 † 0 † 0 † 0 † 0 † -£203 † +£6 †
gov_balance 0 † 0 † 0 † 0 † 0 † +£184 † -£7 †

Household income totals

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Household net income 0 † 0 † 0 † 0 † 0 † -£184 † +£7 †
Household net income (HBAI definition) 0 0 0 0 0 -£173 0

Inequality

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Gini coefficient, equivalised HBAI income before housing costs 0 0 0 0 0 +0.0000 0

Mean household net income in each side's own deciles

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Decile 1: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † 0 †
Decile 2: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † +£0.00 †
Decile 3: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † +£0.00 † -£0.00 †
Decile 4: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † 0 †
Decile 5: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † -£0.00 †
Decile 6: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † +£0.00 †
Decile 7: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † +£0.00 † 0 †
Decile 8: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † 0 †
Decile 9: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † 0 †
Decile 10: mean household_net_income per household 0 † 0 † 0 † 0 † 0 † -£0.00 † 0 †

Standard reform costings (change in gov_balance)

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp 0 0 0 0 0 -£175 -£3
Raise higher rate by 1pp 0 0 0 0 0 +£0 0
Raise personal allowance by ~800GBP/year 0 0 0 0 0 -£4 -£2
Raise child benefit by 25GBP/week per additional child 0 0 0 0 0 -£34 -£1
Reduce Universal Credit taper rate to 20% 0 0 0 0 0 +£27 -£0
Raise Class 1 main employee NICs rate to 10% 0 0 0 0 0 suppressed suppressed
Raise VAT standard rate by 2pp 0 0 0 0 0 -£51 0
Raise additional rate by 3pp 0 0 0 0 0 suppressed suppressed
Raise the Universal Credit standard allowance by 10% 0 0 0 0 0 +£0 0
Raise the State Pension by 10% 0 0 0 0 0 -£88 -£0

The report suppresses a reform's costing and target change in these years, for the reasons below (privacy.py):

  • Raise Class 1 main employee NICs rate to 10%, 2030-31 to 2031-32 (base and head): the reform changes income_tax in fewer than 10 records.
  • Raise additional rate by 3pp, 2030-31 to 2031-32 (base and head): the reform changes universal_credit in fewer than 10 records.

Standard reforms: change in each reform's target

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp: change in income_tax 0 0 0 0 0 -£6 +£0
Raise personal allowance by ~800GBP/year: change in income_tax 0 0 0 0 0 +£0 +£0
Reduce Universal Credit taper rate to 20%: change in universal_credit 0 0 0 0 0 +£23 0
Raise Class 1 main employee NICs rate to 10%: change in national_insurance 0 0 0 0 0 suppressed suppressed
Raise additional rate by 3pp: change in income_tax 0 0 0 0 0 suppressed suppressed
Raise the Universal Credit standard allowance by 10%: change in universal_credit 0 0 0 0 0 -£44 0

Household net income by decile

No 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)
  • VAT excluding private school fees is never negative (sign:vat): 2025-26: 76 units below zero, a weighted £-53,888,684 in total; 2026-27: 76 units below zero, a weighted £-55,777,260 in total; 2027-28: 76 units below zero, a weighted £-57,727,917 in total
Counted-once check

Each 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.

  • widowed_parents_allowance: pension_credit responds only through other heads in fewer than 10 households (other heads that moved there: carers_allowance; gov_spending; not a failure)
  • widowed_parents_allowance: pension_credit responds only through other heads in fewer than 10 households (other heads that moved there: carers_allowance; household_benefits; not a failure)
Provenance
Side Commit Runtime Environment hash Partition hash Time
base main 0080274d78 uk 2.171.0 (files d72b65d494db), core 3.33.12, py 3.13.18 aarch64 c07a637ca255 7cb61324a46b 2115.33s
head #2543 merged into main e1e1b1d13f uk 2.171.0 (files 68b56fc57eef), core 3.33.12, py 3.13.18 aarch64 c07a637ca255 7cb61324a46b 2201.83s

Dataset: hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@2526edbbb687a3b54dbb11272e5389e4b62d2ac2, sha256 056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784 (verified on both sides), policyengine-uk-data 1.58.1.

Receipts by tax head: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Final charge on cash interest in non-cash ISAs £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Lifetime ISA withdrawal charge £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Income tax £294.46bn £315.23bn £335.82bn £351.28bn £366.96bn £385.36bn £400.00bn
National Insurance (employee and self-employed) £51.70bn £54.14bn £56.13bn £57.73bn £59.76bn £61.64bn £63.51bn
National Insurance (employer) £148.71bn £155.37bn £160.88bn £165.09bn £172.06bn £177.36bn £182.57bn
Employer NI change borne by capital £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Employer NI change borne by consumers £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Capital gains tax £12.60bn £13.15bn £13.60bn £14.11bn £14.67bn £15.24bn £15.80bn
Student loan repayments £7.82bn £8.13bn £8.39bn £8.58bn £8.79bn £9.04bn £9.29bn
VAT excluding private school fees £249.37bn £256.87bn £266.33bn £272.70bn £278.97bn £285.24bn £291.59bn
Change in VAT from its baseline £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
VAT on private school fees £1.95bn £2.01bn £2.09bn £2.14bn £2.20bn £2.26bn £2.31bn
Change in private school VAT from its baseline £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Fuel duties £24.71bn £24.13bn £26.00bn £26.83bn £26.51bn £25.78bn £24.63bn
Alcohol duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Tobacco duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Vehicle excise duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Betting and gaming duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
TV licence fee £4.26bn £4.40bn £4.41bn £4.42bn £4.43bn £4.44bn £4.45bn
Council tax £49.21bn £52.16bn £54.89bn £57.95bn £61.16bn £64.47bn £67.91bn
High value council tax surcharge £0.00bn £0.00bn £0.00bn £0.48bn £0.49bn £0.50bn £0.51bn
Northern Ireland domestic rates £0.55bn £0.58bn £0.60bn £0.63bn £0.66bn £0.69bn £0.72bn
Business rates £34.68bn £36.20bn £38.00bn £39.46bn £42.27bn £43.25bn £44.29bn
Stamp duty land tax on the household's own purchases £6.88bn £7.32bn £7.66bn £8.06bn £8.47bn £8.91bn £9.35bn
Stamp duty land tax paid by companies £4.72bn £4.57bn £5.29bn £6.03bn £6.37bn £6.92bn £7.45bn
Land transaction tax (Wales) £0.07bn £0.08bn £0.08bn £0.09bn £0.10bn £0.10bn £0.11bn
Land and buildings transaction tax (Scotland) £0.31bn £0.32bn £0.33bn £0.35bn £0.36bn £0.38bn £0.40bn
Unspecified tax change borne by capital £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Unspecified tax change borne by consumers £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Wealth tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Wealth tax excluding the main residence £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Land value tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Carbon tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Spending by benefit and service: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Lifetime ISA bonus £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
State Pension £121.68bn £125.92bn £126.72bn £129.49bn £132.99bn £136.66bn £140.42bn
Pension Credit £6.66bn £7.03bn £7.29bn £7.48bn £7.60bn £7.84bn £8.03bn
Winter Fuel Payment £1.86bn £1.85bn £1.79bn £1.77bn £1.78bn £1.78bn £1.78bn
Pension Age Winter Heating Payment (Scotland) £0.19bn £0.20bn £0.20bn £0.21bn £0.21bn £0.22bn £0.22bn
Universal Credit £75.48bn £80.52bn £82.95bn £84.96bn £86.11bn £87.61bn £88.21bn
Housing Benefit £8.56bn £8.89bn £9.23bn £9.46bn £9.69bn £9.99bn £10.27bn
Council Tax Reduction £2.07bn £2.20bn £2.31bn £2.45bn £2.58bn £2.72bn £2.86bn
Employment and Support Allowance (income-related) £0.79bn £0.82bn £0.84bn £0.86bn £0.88bn £0.90bn £0.92bn
Employment and Support Allowance (contribution-based) £5.20bn £5.37bn £5.56bn £5.69bn £5.82bn £5.95bn £6.09bn
Jobseeker's Allowance (income-based) £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Jobseeker's Allowance (contribution-based) £0.29bn £0.30bn £0.31bn £0.31bn £0.32bn £0.33bn £0.34bn
Income Support £0.11bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Working Tax Credit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Child Tax Credit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Incapacity Benefit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Personal Independence Payment £26.95bn £28.03bn £29.05bn £30.09bn £30.75bn £31.51bn £32.21bn
Disability Living Allowance £7.49bn £7.79bn £8.07bn £8.36bn £8.55bn £8.76bn £8.95bn
Attendance Allowance £8.68bn £9.03bn £9.36bn £9.69bn £9.90bn £10.15bn £10.38bn
Carer's Allowance £4.48bn £4.66bn £4.83bn £5.00bn £5.11bn £5.27bn £5.42bn
Carer Support Payment (Scotland) £0.31bn £0.32bn £0.33bn £0.34bn £0.35bn £0.36bn £0.37bn
Carer's Allowance Supplement (Scotland) £0.00bn £0.04bn £0.04bn £0.05bn £0.05bn £0.05bn £0.05bn
Severe Disablement Allowance £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn
Industrial Injuries Disablement Benefit £0.50bn £0.52bn £0.54bn £0.55bn £0.56bn £0.58bn £0.59bn
Armed Forces Compensation Scheme £0.61bn £0.63bn £0.65bn £0.66bn £0.68bn £0.69bn £0.71bn
War Widow's or Widower's Pension £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Child Benefit £16.19bn £16.82bn £17.39bn £18.00bn £18.39bn £18.84bn £19.25bn
Scottish Child Payment £0.49bn £0.52bn £0.53bn £0.55bn £0.56bn £0.57bn £0.56bn
Two Child Limit Payment (Scotland, counterfactual) £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Healthy Start vouchers £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn
Maternity Allowance £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Statutory Maternity Pay £3.30bn £3.41bn £3.54bn £3.62bn £3.70bn £3.78bn £3.87bn
Statutory Paternity Pay £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Statutory Sick Pay £1.12bn £1.16bn £1.20bn £1.23bn £1.26bn £1.29bn £1.32bn
Sure Start Maternity Grant £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn
Bereavement Support Payment £0.67bn £0.69bn £0.71bn £0.73bn £0.75bn £0.76bn £0.78bn
Widowed Parent's Allowance £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Tax-Free Childcare £0.56bn £0.57bn £0.57bn £0.58bn £0.60bn £0.61bn £0.59bn
Extended childcare entitlement £2.99bn £3.50bn £3.59bn £3.71bn £3.78bn £3.86bn £3.93bn
Universal childcare entitlement £2.06bn £2.22bn £2.29bn £2.37bn £2.43bn £2.48bn £2.53bn
Targeted childcare entitlement £0.50bn £0.51bn £0.52bn £0.54bn £0.55bn £0.57bn £0.56bn
Childcare Offer for Wales £0.26bn £0.28bn £0.28bn £0.29bn £0.30bn £0.31bn £0.32bn
Care to Learn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Childcare Grant £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Parents' Learning Allowance £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn
Adult Dependants' Grant £0.06bn £0.06bn £0.04bn £0.04bn £0.04bn £0.04bn £0.04bn
Travel Grant £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed
16 to 19 Bursary Fund £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
NHS spending £203.89bn £204.30bn £204.56bn £204.91bn £205.34bn £205.85bn £206.35bn
State education spending £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn
Transport subsidy spending £25.05bn £25.10bn £25.14bn £25.18bn £25.23bn £25.30bn £25.36bn
Cost-of-living support payment £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Energy Bills Rebate £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Household Energy Payment £0.00bn £2.11bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Energy Price Guarantee subsidy £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Basic income £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Disability basic income £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Other public spending budget change £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Caseloads: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Lifetime ISA bonus 0k 0k 0k 0k 0k 0k 0k
State Pension 11,264k 11,145k 10,819k 10,700k 10,723k 10,750k 10,776k
Pension Credit 1,486k 1,480k 1,472k 1,452k 1,450k 1,455k 1,464k
Winter Fuel Payment 7,844k 7,783k 7,473k 7,375k 7,391k 7,409k 7,427k
Pension Age Winter Heating Payment (Scotland) 803k 802k 800k 797k 799k 801k 802k
Universal Credit 6,342k 6,347k 6,336k 6,297k 6,292k 6,271k 6,223k
Housing Benefit 1,249k 1,245k 1,244k 1,227k 1,227k 1,229k 1,232k
Council Tax Reduction 3,176k 3,195k 3,200k 3,214k 3,224k 3,235k 3,242k
Employment and Support Allowance (income-related) 71k 71k 71k 71k 71k 71k 71k
Employment and Support Allowance (contribution-based) 719k 720k 721k 722k 724k 726k 728k
Jobseeker's Allowance (income-based) 0k 0k 0k 0k 0k 0k 0k
Jobseeker's Allowance (contribution-based) 67k 67k 67k 67k 67k 68k 68k
Income Support 25k 0k 0k 0k 0k 0k 0k
Working Tax Credit 0k 0k 0k 0k 0k 0k 0k
Child Tax Credit 0k 0k 0k 0k 0k 0k 0k
Incapacity Benefit 0k 0k 0k 0k 0k 0k 0k
Personal Independence Payment 3,567k 3,575k 3,579k 3,585k 3,593k 3,602k 3,611k
Disability Living Allowance 1,291k 1,294k 1,296k 1,298k 1,300k 1,304k 1,307k
Attendance Allowance 1,695k 1,698k 1,700k 1,703k 1,707k 1,711k 1,715k
Carer's Allowance 1,034k 1,037k 1,038k 1,040k 1,042k 1,060k 1,062k
Carer Support Payment (Scotland) 71k 71k 71k 72k 72k 72k 72k
Carer's Allowance Supplement (Scotland) 0k 71k 71k 72k 72k 72k 72k
Severe Disablement Allowance 4k 4k 4k 4k 4k 4k 4k
Industrial Injuries Disablement Benefit 137k 137k 137k 138k 138k 138k 139k
Armed Forces Compensation Scheme 93k 93k 94k 94k 94k 94k 94k
War Widow's or Widower's Pension 0k 0k 0k 0k 0k 0k 0k
Child Benefit 8,123k 8,120k 8,120k 8,130k 8,145k 8,161k 8,178k
Scottish Child Payment 351k 352k 352k 353k 350k 348k 337k
Two Child Limit Payment (Scotland, counterfactual) 0k 0k 0k 0k 0k 0k 0k
Healthy Start vouchers 200k 200k 200k 201k 201k 201k 202k
Maternity Allowance 0k 0k 0k 0k 0k 0k 0k
Statutory Maternity Pay 333k 333k 334k 334k 335k 336k 337k
Statutory Paternity Pay 0k 0k 0k 0k 0k 0k 0k
Statutory Sick Pay 181k 182k 182k 182k 182k 183k 183k
Sure Start Maternity Grant 26k 26k 26k 26k 26k 26k 26k
Bereavement Support Payment 98k 98k 98k 98k 98k 99k 99k
Widowed Parent's Allowance 0k 0k 0k 0k 0k 0k 0k
Tax-Free Childcare 1,043k 1,027k 1,014k 1,009k 1,011k 1,009k 993k
Extended childcare entitlement 795k 800k 796k 797k 798k 798k 792k
Universal childcare entitlement 1,071k 1,072k 1,072k 1,073k 1,076k 1,079k 1,074k
Targeted childcare entitlement 165k 156k 154k 154k 155k 155k 150k
Childcare Offer for Wales 39k 39k 39k 39k 39k 39k 39k
Care to Learn 0k 0k 0k 0k 0k 0k 0k
Childcare Grant 0k 0k 0k 0k 0k 0k 0k
Parents' Learning Allowance 98k 98k 98k 99k 99k 99k 99k
Adult Dependants' Grant 18k 18k 12k 12k 12k 12k 12k
Travel Grant 0k 0k 0k 0k 0k 0k 0k
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed
16 to 19 Bursary Fund 0k 0k 0k 0k 0k 0k 0k
Cost-of-living support payment 0k 0k 0k 0k 0k 0k 0k
Energy Bills Rebate 0k 0k 0k 0k 0k 0k 0k
Household Energy Payment 0k 21,065k 0k 0k 0k 0k 0k
Energy Price Guarantee subsidy 0k 0k 0k 0k 0k 0k 0k
Basic income 0k 0k 0k 0k 0k 0k 0k
Disability basic income 0k 0k 0k 0k 0k 0k 0k
Government balance, each component counted once: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Government receipts, each component once £892.00bn £934.66bn £980.50bn £1,015.93bn £1,054.24bn £1,091.56bn £1,124.89bn
Government spending £529.38bn † £545.66bn † £550.77bn † £559.51bn † £567.20bn † £575.95bn † £583.60bn †
Government balance, each receipt once £362.62bn † £389.00bn † £429.74bn † £456.42bn † £487.04bn † £515.61bn † £541.29bn †
Benefits in household_benefits but not in gov_spending £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Model totals: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
gov_tax £892.00bn £934.66bn £980.50bn £1,015.93bn £1,054.24bn £1,091.56bn £1,124.89bn
household_tax £491.97bn £520.41bn £551.21bn £576.01bn £601.00bn £626.70bn £648.42bn
pre_budget_change_household_tax £484.15bn £512.28bn £542.82bn £566.95bn £591.72bn £617.16bn £638.61bn
gov_spending £529.38bn † £545.66bn † £550.77bn † £559.51bn † £567.20bn † £575.95bn † £583.60bn †
household_benefits £529.38bn † £545.66bn † £550.77bn † £559.51bn † £567.20bn † £575.95bn † £583.60bn †
gov_balance £362.62bn † £389.00bn † £429.74bn † £456.42bn † £487.04bn † £515.61bn † £541.29bn †
Household income totals: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Household market income £1,637.96bn £1,702.77bn £1,757.35bn £1,806.41bn £1,855.97bn £1,912.43bn £1,970.44bn
Household net income £1,636.69bn † £1,687.82bn † £1,715.45bn † £1,747.49bn † £1,778.69bn † £1,817.02bn † £1,859.69bn †
Household net income (HBAI definition) £1,449.57bn £1,499.83bn £1,530.86bn £1,565.17bn £1,597.89bn £1,635.60bn £1,676.61bn
Population: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
People 69,245k 69,383k 69,473k 69,592k 69,738k 69,912k 70,080k
Benefit units 35,110k 35,181k 35,226k 35,286k 35,360k 35,449k 35,534k
Households 31,046k 31,108k 31,149k 31,201k 31,267k 31,345k 31,420k
Poverty rates: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Absolute poverty, before housing costs: everyone 16.3% 15.5% 16.4% 16.3% 16.5% 16.5% 16.6%
Absolute poverty, before housing costs: children 24.7% 22.1% 23.3% 22.9% 23.1% 23.2% 23.5%
Absolute poverty, before housing costs: working-age adults 14.1% 13.8% 14.7% 14.7% 14.8% 14.8% 15.0%
Absolute poverty, before housing costs: pensioners 13.7% 13.4% 14.0% 13.8% 13.9% 13.9% 13.9%
Absolute poverty, after housing costs: everyone 21.6% 21.0% 22.0% 22.6% 22.9% 23.1% 23.4%
Absolute poverty, after housing costs: children 33.3% 31.3% 32.3% 32.5% 32.9% 33.4% 34.2%
Absolute poverty, after housing costs: working-age adults 19.9% 19.8% 20.8% 21.6% 21.9% 22.0% 22.3%
Absolute poverty, after housing costs: pensioners 12.7% 12.4% 13.2% 13.2% 13.3% 13.4% 13.6%
Relative poverty, before housing costs: everyone 17.0% 16.0% 16.4% 16.3% 16.4% 16.3% 16.6%
Relative poverty, before housing costs: children 25.9% 22.9% 23.3% 22.9% 23.0% 22.9% 23.5%
Relative poverty, before housing costs: working-age adults 14.5% 14.1% 14.6% 14.7% 14.7% 14.7% 15.0%
Relative poverty, before housing costs: pensioners 14.5% 14.0% 13.9% 13.8% 13.8% 13.9% 13.9%
Relative poverty, after housing costs: everyone 21.4% 21.0% 21.3% 21.3% 21.3% 21.5% 21.7%
Relative poverty, after housing costs: children 33.1% 31.1% 31.2% 31.0% 31.1% 31.2% 31.8%
Relative poverty, after housing costs: working-age adults 19.9% 19.8% 20.2% 20.4% 20.5% 20.6% 20.8%
Relative poverty, after housing costs: pensioners 12.5% 12.4% 12.2% 11.7% 11.7% 11.8% 12.0%
People in poverty: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Absolute poverty, before housing costs: everyone 11,278k 10,779k 11,402k 11,365k 11,476k 11,513k 11,651k
Absolute poverty, before housing costs: children 3,688k 3,314k 3,496k 3,445k 3,480k 3,502k 3,549k
Absolute poverty, before housing costs: working-age adults 5,965k 5,893k 6,308k 6,362k 6,420k 6,430k 6,521k
Absolute poverty, before housing costs: pensioners 1,624k 1,572k 1,598k 1,558k 1,576k 1,580k 1,581k
Absolute poverty, after housing costs: everyone 14,944k 14,600k 15,310k 15,721k 15,937k 16,141k 16,426k
Absolute poverty, after housing costs: children 4,975k 4,682k 4,837k 4,888k 4,945k 5,040k 5,179k
Absolute poverty, after housing costs: working-age adults 8,463k 8,461k 8,971k 9,344k 9,491k 9,584k 9,706k
Absolute poverty, after housing costs: pensioners 1,506k 1,457k 1,502k 1,490k 1,501k 1,517k 1,542k
Relative poverty, before housing costs: everyone 11,750k 11,084k 11,388k 11,364k 11,416k 11,415k 11,651k
Relative poverty, before housing costs: children 3,872k 3,423k 3,496k 3,445k 3,466k 3,454k 3,549k
Relative poverty, before housing costs: working-age adults 6,159k 6,013k 6,301k 6,362k 6,389k 6,389k 6,521k
Relative poverty, before housing costs: pensioners 1,720k 1,648k 1,590k 1,557k 1,562k 1,572k 1,581k
Relative poverty, after housing costs: everyone 14,849k 14,552k 14,776k 14,809k 14,874k 15,014k 15,234k
Relative poverty, after housing costs: children 4,942k 4,660k 4,673k 4,663k 4,679k 4,707k 4,806k
Relative poverty, after housing costs: working-age adults 8,426k 8,440k 8,711k 8,823k 8,873k 8,967k 9,062k
Relative poverty, after housing costs: pensioners 1,482k 1,452k 1,392k 1,323k 1,322k 1,339k 1,365k
Inequality: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Gini coefficient, equivalised HBAI income before housing costs 0.3080 0.3052 0.3058 0.3058 0.3063 0.3069 0.3085
Mean household net income in each side's own deciles: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Decile 1: mean household_net_income per household £17,681 † £17,997 † £17,915 † £18,152 † £18,307 † £18,531 † £18,717 †
Decile 2: mean household_net_income per household £27,939 † £29,023 † £28,711 † £29,013 † £29,276 † £29,691 † £30,150 †
Decile 3: mean household_net_income per household £34,320 † £35,485 † £36,073 † £36,611 † £37,197 † £37,737 † £38,187 †
Decile 4: mean household_net_income per household £38,933 † £40,687 † £41,723 † £42,727 † £43,334 † £44,132 † £44,432 †
Decile 5: mean household_net_income per household £43,239 † £45,567 † £46,014 † £46,959 † £47,712 † £48,584 † £50,282 †
Decile 6: mean household_net_income per household £47,927 † £49,210 † £50,892 † £51,939 † £52,945 † £53,942 † £54,680 †
Decile 7: mean household_net_income per household £55,662 † £56,571 † £57,442 † £57,670 † £58,729 † £59,657 † £61,082 †
Decile 8: mean household_net_income per household £68,789 † £70,803 † £71,858 † £73,466 † £73,880 † £75,320 † £77,178 †
Decile 9: mean household_net_income per household £78,890 † £81,013 † £80,861 † £82,646 † £85,464 † £86,946 † £89,509 †
Decile 10: mean household_net_income per household £117,005 † £120,339 † £124,742 † £126,632 † £125,971 † £128,600 † £130,938 †
Standard reform costings (change in gov_balance): head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp £6.41bn £6.66bn £6.63bn £6.85bn £7.03bn £7.25bn £7.49bn
Raise higher rate by 1pp £3.61bn £3.87bn £4.00bn £4.22bn £4.44bn £4.71bn £4.83bn
Raise personal allowance by ~800GBP/year £-3.80bn £-3.92bn £-3.98bn £-4.04bn £-4.10bn £-4.23bn £-1.95bn
Raise child benefit by 25GBP/week per additional child £-1.99bn £-1.75bn £-1.55bn £-1.38bn £-1.30bn £-1.18bn £-1.07bn
Reduce Universal Credit taper rate to 20% £-20.77bn £-21.32bn £-21.75bn £-21.99bn £-22.34bn £-22.75bn £-22.94bn
Raise Class 1 main employee NICs rate to 10% £10.89bn £11.30bn £11.71bn £12.01bn £12.31bn suppressed suppressed
Raise VAT standard rate by 2pp £24.89bn £25.71bn £26.63bn £27.24bn £27.87bn £28.50bn £29.14bn
Raise additional rate by 3pp £2.90bn £3.15bn £3.22bn £3.41bn £3.61bn suppressed suppressed
Raise the Universal Credit standard allowance by 10% £-3.06bn £-3.18bn £-3.26bn £-3.35bn £-3.44bn £-3.49bn £-3.50bn
Raise the State Pension by 10% £-7.78bn £-8.01bn £-8.24bn £-8.47bn £-8.82bn £-8.55bn £-8.89bn
Standard reforms: change in each reform's target: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp: change in income_tax £6.55bn £6.86bn £6.79bn £7.00bn £7.20bn £7.44bn £7.66bn
Raise higher rate by 1pp: change in income_tax £3.61bn £3.92bn £4.01bn £4.23bn £4.45bn £4.72bn £4.84bn
Raise personal allowance by ~800GBP/year: change in income_tax £-3.93bn £-4.07bn £-4.13bn £-4.18bn £-4.22bn £-4.40bn £-1.75bn
Raise child benefit by 25GBP/week per additional child: change in child_benefit £2.33bn £2.14bn £1.94bn £1.76bn £1.65bn £1.52bn £1.40bn
Reduce Universal Credit taper rate to 20%: change in universal_credit £20.74bn £21.31bn £21.78bn £22.04bn £22.37bn £22.78bn £22.99bn
Raise Class 1 main employee NICs rate to 10%: change in national_insurance £11.15bn £11.63bn £12.00bn £12.29bn £12.61bn suppressed suppressed
Raise VAT standard rate by 2pp: change in vat £24.69bn £25.51bn £26.42bn £27.03bn £27.65bn £28.28bn £28.91bn
Raise additional rate by 3pp: change in income_tax £2.90bn £3.15bn £3.22bn £3.42bn £3.62bn suppressed suppressed
Raise the Universal Credit standard allowance by 10%: change in universal_credit £3.06bn £3.19bn £3.28bn £3.37bn £3.43bn £3.49bn £3.51bn
Raise the State Pension by 10%: change in state_pension £10.68bn £11.17bn £11.28bn £11.60bn £12.03bn £12.47bn £12.97bn

MaxGhenis and others added 4 commits October 21, 2026 05:10
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22 juaristi22 self-assigned this Oct 22, 2026

@juaristi22 juaristi22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Heads-up on a semantic interaction with #2699 and #2700. There's no file overlap.

test_a_penny_a_week_over_each_limit_is_over_it_in_every_year builds LIMITS_BY_YEAR from the YAML values in force on 30 April up to HORIZON. _penny_steps_misjudged then asserts that system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit == limit, and the test asserts the set of limits equals the YAML's. Once #2700 lands, both fail from 2027.

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 limits

Restrict the set-equality assertion to SET_YEARS. The penny steps (52L, 52L + £0.51, 52L + £0.52) are just as useful at the projected limits.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 27, 2026 14:40
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
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

@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:

  • carers_allowance_earnings works in float64 pence from the whole pence each input was entered as (policyengine_uk/utils/pence.py:stored_pence). Pay and profit are taken down to a whole penny. Each deduction is taken up to one: PAYE, Class 1, notional tax, Class 4, Class 2 and half of pension contributions. Net earnings are therefore whole pence.
  • is_gainfully_employed_for_carers_allowance reads those pence back exactly, divides by 52 with the fraction disregarded, and tests strictly against the limit.

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:

  • YAML Case 25.
  • An explicit row of the new test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny, which also checks 1,210 trading and employee pairs (2024-25 and 2026-27) with fractions in their deductions against exact arithmetic, deduction by deduction.
  • sweep_offgrid.py: 0 wrong at this head, against 455 and 115 on main and 609 and 219 at a67d545.

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.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

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 _limits_by_year change covers it, so the earlier heads-up no longer applies.

MaxGhenis and others added 4 commits October 28, 2026 06:23
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>
@MaxGhenis MaxGhenis mentioned this pull request Oct 28, 2026
40 tasks done
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

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 is_gainfully_employed_for_carers_allowance into a new variable, carers_allowance_weekly_earnings_above_limit. That variable feeds both the gainful-employment test and the 100% taper. If #2719 lands first, this PR's pence change belongs in that new variable, and the taper picks it up too.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 28, 2026 13:04
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>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

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
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head: c4b6335160c813f3326f6828790031e23ce0c252 on claude/carers-allowance-pence-fpnbsw (my harness only lets me push to that branch). It merges origin/main at 2da212ab into this PR's head 2502953b, with no rebase: the merge commit 70c795e7 has 2502953b as its first parent. The PR branch fix/2541-ca-earnings-pence is unchanged, so no CI has run. To land it, move the PR branch to c4b6335.

1. Merge and where the pence step lives

  • I resolved the conflict in is_gainfully_employed_for_carers_allowance.py by keeping main's form: excess > 0 (line 39). Its docs now point to the new variable.
  • The PR's exact whole-pence step now lives in carers_allowance_weekly_earnings_above_limit.py:46-63:
    • floor(stored_pence(earnings)), then floor(/52);
    • the limit in pence via np.round(limit * 100, 6);
    • returns max_(0, weekly_pence - limit_pence) / 100.
      The reg 8(1)(b)(iii) and 11(1)(a) references moved with it.
  • The taper (policyengine_uk/utils/carers_allowance_taper.py:65-71) now reads the excess as stored_pence(excess) / 100.

2. Checks on the merged logic

  • Exact pence: the excess is exactly 1p, 2p and 100p for a carer 1p, 2p and £1 a week over the limit, in every year from 2020 to 2041. The gainful-employment flag is true exactly when the excess is positive. I checked this with a hand probe and with the new tests.
  • The taper reads the amount over the limit: weekly award = rate − taper.rate × excess, floored at 0, in England and Wales.
    • New test_carers_allowance_weekly_earnings_above_limit_properties.py::test_a_penny_two_pence_and_a_pound_over_each_limit_taper_exactly, run for each year 2020–2041. The taper is applied by reform in every year, so every limit from 2020-21 to the parameter horizon is covered, including the projected £212 from 2027-28.
    • From 2027 the model's projected rates aren't whole pence (2027: £89.4756…), so the award is checked to within one float32 step of the annual award. The excess is checked exactly.
  • _limits_by_year: it still reads the model's limit after the last legislated year. HORIZON is 2041, and the limits run £212, £217, £222, £228, … £309. Its set-equality check passed.
  • Fractions of a penny: an amount entered with a fraction still has it disregarded (floor(stored_pence)). The existing exact-arithmetic property test passes.

3. Tests (taper properties are in test_the_taper_follows_exact_pence: Hypothesis, 12 examples × 40 carers, plus explicit examples at the four #2541 limits)

  • The four properties asked for: never more than the full rate; never negative; the weekly award falls by no more than the excess rises (and is at least rate − excess); the full rate at or under the limit. It also checks exact excess and exact award.
  • pytest on the two named property files, test_carers_allowance_earnings_taper.py (the only test on main that reads the new variable), test_carers_allowance_earnings_limit_projection.py, the new module, test_carer_support_payment.py and test_stored_pence.py: 178 passed, 1 failed at 2488a1c.
    • The failure was test_a_penny_a_week_over_each_limit_is_over_it_in_every_year, in 2030–2041 only. It expected nil allowance a penny over the limit, but main's taper pays from 2030-31 (2030: £2,500.44 for the taper weeks).
    • Judgement call: c4b6335 switches the taper off in _penny_steps_misjudged (test_carers_allowance_earnings_limit_properties.py:299-316), so that test checks the cliff edge, and the new module checks the same steps with the taper on. After that, the test and test_pay_a_penny_a_week_over_each_limit_ends_entitlement pass: 2 passed.
  • YAML: policyengine-core test on all 72 YAML files under tests/policy that mention carers, carers_allowance or care_hours, including the four CA files and the CSP earnings-limit file: 1,318 passed.
  • tests/code_health: 3,087 passed, 1 failed at 2488a1c.
    • The failure: shard_durations.toml still named test_net_earnings_never_fall_as_pay_rises, which this branch had already replaced, so the failure predates the merge.
    • c4b6335 deletes that line, and test_ci_test_shards.py then gives 51 passed.
    • I didn't rerun the whole code_health directory after that one-line deletion.
  • Ruff 0.17.2 format --check ., check . and check --select F811,F821 policyengine_uk, and git diff --check: clean.

Revert check. In a scratch copy, I restored main's float32 carers_allowance_weekly_earnings_above_limit and left everything else at this head. All 23 new tests failed:

I didn't run a separate check that removes only the taper's stored_pence read. Without it, a float32 excess of 1p is 0.0099999998, which moves the weekly award by about 2e-10 and can't show in a float32 annual award. That read is for exactness, and no test can tell it apart.

4. PR body

  • Updated: Fixes #2541, then the Reproducer: line (kept, plus the new per-year taper test), then the existing axiom: line.
  • New sections: the pence step in the new variable, a section on the taper reading exact pence, invariant 11, and local runs at this head.
  • The review section now lists the findings and how each was answered, without naming reviewers.
  • The changelog fragment changelog.d/2541.fixed.md is kept, and now names the new variable and the taper.

Skipped

  • I didn't rerun the earlier sweeps (~/reviews/uk-2541-ca-pence/…): they aren't in this session. The body says they ran at earlier heads.
  • Aggregate impact at this head is pending CI. It needs the survey data, which I don't have.
  • No survey-dataset tests were run.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Verdict: APPROVE WITH NITS at c4b6335160c813f3326f6828790031e23ce0c252 (claude/carers-allowance-pence-fpnbsw)

I reviewed git diff 2502953bf..c4b6335, the merge 70c795e7 (parents 2502953bf, 2da212abc) and the PR's net change against main (git diff 2da212abc..c4b6335, 13 files). I checked it out detached in a scratch clone, ran uv sync --frozen --extra dev, and unset HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET.

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.

P1

None.

P2

P2-1. The hand-off is wrong that no test can detect the taper's stored_pence read. Seven tests fail without it.
The hand-off says: "Without it, a float32 excess of 1p is 0.0099999998, which moves the weekly award by about 2e-10 and can't show in a float32 annual award. That read is for exactness, and no test can tell it apart."

  • Without the read, ca.rate - taper.rate * excess runs in float32, because the excess array is float32. With it, the subtraction runs in float64, because stored_pence returns float64. The read therefore changes the award by more than a float32 step of the annual amount.
  • Mutation: in a scratch copy I replaced stored_pence(person("carers_allowance_weekly_earnings_above_limit", period)) / 100 with person("carers_allowance_weekly_earnings_above_limit", period) in policyengine_uk/utils/carers_allowance_taper.py:68-71. Then I ran pytest test_carers_allowance_weekly_earnings_above_limit_properties.py plus the two test_carers_allowance_earnings_limit_properties.py penny-step tests, -n 4.
    • Result: 7 failed, 18 passed (killed).
    • Failures: test_the_taper_follows_exact_pence and test_a_penny_two_pence_and_a_pound_over_each_limit_taper_exactly[2021, 2022, 2025, 2039, 2040, 2041].
    • For 2021, a penny a week over the limit, the award is 351,467.96875p (£3,514.6796875) against the expected 351,468p (£3,514.68). That is outside the test's one-float32-step tolerance.
  • The code and the PR body are right. Only the hand-off's claim is false. A reader acting on it might remove a read that the tests depend on.
  • Suggested replacement for the code comment at carers_allowance_taper.py:66-67:

    # The excess is whole pence, which float32 holds only to within half a step (1p as 0.0099999998); stored_pence reads them back exactly, in float64, so the formula subtracts them from the rate in float64.

P2-2. The "never negative" property can't fail when the taper's own floor is removed.

  • test_the_taper_follows_exact_pence asserts award >= 0 only on carers_allowance. carers_allowance.py:85 floors that at zero again (max_(pre_overlap - share_paid * overlapping, 0)).
  • Mutation M3: I removed np.maximum(0, …) around ca.rate - taper.rate * excess in carers_allowance_taper.py:72-74. The same tests gave 25 passed (survived).
  • That mutant isn't harmless. For a carer in England in 2031 with weekly earnings £166 over the limit, carers_allowance_pre_couple_limit and carers_allowance_pre_overlap are both −£3,516.58, while carers_allowance is £0. I ran this with a hand-built household and the mutated file loaded.
    • is_carer_for_benefits, partners_care_for_same_severely_disabled_person and disable_simulated_benefits each read one or both of them.
  • Fix: in _run, also return carers_allowance_pre_couple_limit, and add assert (pre_couple >= 0).all(), year and assert (pre_couple <= WEEKS * rate + WEEKS * slack).all(), year beside the checks at test_carers_allowance_weekly_earnings_above_limit_properties.py:193-195.

P2-3. The PR body's effect table still gives the 2027–2028 limits as £196 and £204.

  • On main the model projects £212 for 2027-28 and £217 for 2028-29 (Project the minimum wage rates by the OBR's national living wage forecast #2700). I printed system.parameters(f"{year}-04-30").gov.dwp.carers_allowance.earnings_limit and got 212.0, 217.0, 222.0, 228.0, … 309.0 up to 2041.
  • I ran main's formula (2da212abc's carers_allowance_weekly_earnings_above_limit, everything else at this head) for 2026 to 2029, with carers_allowance_earnings entered at 52L + £0.52.
    • Each year gives an excess of 0.99945068p, gainfully employed, and CA nil.
    • This head gives exactly 1p and the same flag and award.
    • So "unchanged" holds, but the limits in the row are wrong.
  • Replace the row | 2025–2028 | £196, £204 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged | with:

    | 2025–2026 | £196, £204 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged |
    | 2027–2029 | £212, £217, £222 (projected) | 52L + 0.52 (entered as earnings) | L + £0.01 | gainfully employed, CA nil | unchanged |

  • After the table, add:

    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).

P3

P3-1. Stale "#2699 proposes" in the body's Tests section.
The projection is already on main. Replace "so a projection of the limit (as #2699 proposes) is covered rather than breaking the test" with:

"so the model's projected limits from 2027-28 (£212, rising to £309 from 2039-40) are covered."

P3-2. A comment and a docstring say the excess is always whole pence.

  • They are is_gainfully_employed_for_carers_allowance.py:36-38 ("a whole number of pence of at least 1p") and the documentation of carers_allowance_weekly_earnings_above_limit ("in whole pence").
  • Both hold only for a limit in whole pence. Under a reform that sets the limit to £204.105, earnings of 52 × £204.11 give an excess of 0.004999999888 (half a penny) and a flag of True, from a hand-built household at this head.
  • The code is right, because any positive excess means the carer is gainfully employed. Only the text overclaims.
  • Replace the comment with:

    # The excess is positive wherever earnings exceed the limit (at least 1p for a limit in whole pence), which float32 holds as a positive amount, and exactly zero otherwise.

P3-3. The changelog fragment breaks the length rule and starts a sentence with a lower-case identifier.

  • changelog.d/2541.fixed.md has eight sentences against the rule's one or two. The new sentence starts with the lower-case identifier carers_allowance_weekly_earnings_above_limit.
  • Suggested text:

    Fixed the Carer's Allowance earnings limit treating earnings a penny a week over the 2020-21 to 2024-25 limits (£128, £132, £139 and £151) as within them, and the Budget 2026 earnings taper taking slightly less than the pence above the limit off the award. Net earnings now apply the Computation of Earnings Regulations' rounding to each step in exact pence, and the formula gives the weekly excess over the limit in whole pence, for Carer's Allowance and the Scottish Carer Support Payment.

P3-4. Writing rules in the PR body and documentation.

  • The body still names review work and local paths. Examples:
    • "(written by the review of 32f1639)";
    • "probe_rows.py in the review's salvage";
    • "the seven employee rows the review of 32f1639 found";
    • "The review's case is YAML Case 25";
    • ~/reviews/uk-2541-ca-pence/….
  • Suggested rewording: "(a script that works Class 1 out exactly)", "the seven employee rows found at 32f1639", and "The case is YAML Case 25". Drop the ~/reviews/… paths, or mark the sweep results as run at earlier heads, as the body already does in one place.
  • In the documentation of carers_allowance_weekly_earnings_above_limit, "The regulation tests the earnings of the preceding week" makes a regulation act. Replace it with:

    "The formula spreads annual earnings evenly over 52 weeks to stand for the earnings of the preceding week (ICA Regulations reg. 8(1))."

P3-5. The body doesn't describe how Class 1 is chosen.

  • The body (rule item 1, the Class 1 bullet) says only that the exact Class 1 is used where it agrees with the stored total within float32 error.
  • The code at carers_allowance_earnings.py:151-209 first checks provenance (_class_1_calculated). Class 1 supplied as an input, or calculated under a reform, is deducted as supplied or calculated, with its months summed exactly. The changelog says this, but the body doesn't.
  • This predates the delta, but step 4 of the brief asked for the body to say what the code does now. Add:

    "Where Class 1 is supplied as an input, for the year or month by month, or a reform calculates it, the formula deducts it as supplied or calculated, with its months summed exactly."

Checked and sound

  • The merge.
    • git merge-tree --write-tree 2502953bf 2da212abc conflicts only in is_gainfully_employed_for_carers_allowance.py.
    • git diff <auto-merge tree> 70c795e7 touches only that file and carers_allowance_weekly_earnings_above_limit.py. So the merge carries no other edits.
    • The resolution keeps main's excess > 0. Against the new variable that is equivalent to the PR's weekly_pence > np.round(limit * 100, 6): the excess is max_(0, weekly_pence − limit_pence) / 100, which is positive in float32 whenever the difference exceeds zero. That includes a 0.5p excess under a fractional reformed limit, which I ran.
    • git diff 70c795e7..c4b6335 touches only the five files the hand-off names.
  • Exact pence at every limit, 2020–2041. I wrote my own probe, separate from the PR's tests, that enters carers_allowance_earnings at 52 × (100L + s) + e pence for s ∈ {0, 1, 2, 100} and e ∈ {0, 51}, with the taper forced on by reform in every year.
    • 0 wrong of 176 cases.
    • The excess is exactly s pence. The flag is true exactly when s > 0. The award is 52 × (rate − s/100) to within a penny: for example £3,496.48 at 1p over in 2020-21 and £5,992.99 in 2041-42.
  • The baseline (no reform) gives the same flags. CA is nil above the limit to 2029. In 2030-31 the award a penny over is £2,500.44, because the taper's share of 2030-31 is 0.4986 (182 of 365 days). From 2031 it is the rate less the excess. This matches the hand-off.
  • The limits. The parameter tree gives £212, £217, £222, £228, £234, £241, £248, £257, £266, £276, £287 and £298, then £309 for 2039–2041. The rates from 2027 are projections that aren't whole pence (2027: 89.4756). _limits_by_year(HORIZON) covers every year from 2015 to 2041. The set-equality assertion passes.
  • Mutation checks (scratch copy, each restored afterwards; tests are the new module plus test_a_penny_a_week_over_each_limit_is_over_it_in_every_year and test_pay_a_penny_a_week_over_each_limit_ends_entitlement, 25 tests):
Mutation Result
M1: main's float32 carers_allowance_weekly_earnings_above_limit killed, 25 failed (all 23 new tests and both penny-step tests)
M2: excess as np.float32(weekly_pence/100) − np.float32(limit) killed, 23 failed
M3: taper not floored at zero survived, 25 passed (P2-2)
M4: taper takes 101% of the excess killed, 23 failed
M5: weekly pence not floored (reg 5 fraction kept) killed, 25 failed
M6: taper reads the excess in whole pounds killed, 23 failed
M7: taper reads the float32 excess without stored_pence killed, 7 failed (P2-1)
M8: excess counted from the limit (+1p, so >=) killed, 25 failed

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.

  • Fractions of a penny. annual_pence = np.floor(stored_pence(earnings)) disregards an entered fraction. The 51p-a-year cases give the same excess as 0p in every year.

  • Tests at this head, Python 3.13, -n 4.

    • Pytest, 179 passed, 0 failed, on:

      • test_carers_allowance_earnings_limit_properties.py
      • test_carers_allowance_pension_contribution_deduction_rate_properties.py
      • test_carers_allowance_earnings_taper.py
      • test_carers_allowance_earnings_limit_projection.py
      • test_carers_allowance_weekly_earnings_above_limit_properties.py
      • test_carer_support_payment.py
      • test_stored_pence.py

      This agrees with the hand-off's 178 + 1 at 2488a1c, after c4b6335's change.

    • policyengine-core test on the 72 YAML files under tests/policy that match carer|care_hours: 1,318 passed, which matches the body.

    • pytest policyengine_uk/tests/code_health/test_ci_test_shards.py: 51 passed.

  • Lint. Ruff 0.17.2 format --check . (2,167 files formatted), check . and check --select F811,F821 policyengine_uk all pass, and git diff --check 2da212abc c4b6335 is clean.

  • Fix evidence. .github/check_fix_pr.py, run locally on the PR's current title and body with git diff -z --name-status 2da212abc...c4b6335 and --head-sha c4b6335160c813f3326f6828790031e23ce0c252, gives PASS ("Changed executable tests: 6").

  • The PR body's other figures touched by this delta. These match my runs:

    • the 2020–2024 "Before" awards (£3,497.00, £3,515.20, £3,624.40, £3,991.00, £4,258.80);
    • 0.99945p under main from 2025-26;
    • £2,500.44 in 2030-31;
    • HORIZON 2041.

    The title is 51 characters. The first lines are in order: Fixes #2541, then Reproducer:, then axiom:.

  • Earlier findings.

    • C1 (nearest-half-penny rounding, at a67d545) stays closed: YAML Case 25 passes inside the 1,318.
    • The step-4 body update is partly closed: P2-3, P3-1, P3-4 and P3-5 remain.
    • Every other item in the brief is closed:

What I couldn't check

  • The sweeps and studies under ~/reviews/uk-2541-ca-pence/ and r5/ on the hub's machine, and their counts in the body: the exhaustive sets A–C, sweep_offgrid, sweep_class1_exact, sweep_pension_pairs, the swap-check table and class1_tolerance_study. They are unverified at any head.
  • Aggregate impact and any test that needs survey data: there is no dataset in this session.
  • The whole code_health directory. I ran only the shard test.
  • Main has moved past the hold. origin/main is now a8ddb927 (Round projected State Pension weekly rates to the nearest 5p each April #2751, State Pension rounding). None of its changed files touch Carer's Allowance, pence or shards, and git merge-tree c4b6335 a8ddb927 merges cleanly. I ran no tests on that combination.

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
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

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
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_TITLE.txt

Count Carer's Allowance earnings-limit pence exactly

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_BODY.md

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_exactly`
axiom: https://github.com/TheAxiomFoundation/rulespec-uk/issues/482 queued

## What and why

`carers_allowance_weekly_earnings_above_limit` gives how far a carer's weekly earnings exceed the Carer's Allowance earnings limit, and `is_gainfully_employed_for_carers_allowance` asks 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:

- **Reg 5** says that where "any calculation under these Regulations results in a fraction of a penny", the fraction "shall, if it would be to the claimant's advantage, be treated as a penny, otherwise it shall be disregarded".
- **Reg 8(1)(b)(iii)** turns a yearly payment into a weekly one "by dividing the amount of the payment by 52".
- **Reg 10(4)** nets PAYE, primary Class 1 and "one half of any sum paid … by way of a contribution towards an occupational or personal pension scheme" off pay. **Regs 13 and 14** net a notional basic-rate income tax, Class 4 (and, before 2024-25, Class 2) and half of pension premiums off trading profit.

On main, `carers_allowance_weekly_earnings_above_limit` floors the weekly pence in float32, the dtype of `carers_allowance_earnings`: `np.floor(weekly * 100 + 1e-6) / 100`, less the limit. At these magnitudes the `+ 1e-6` does nothing, because float32 spacing near 20,400 is 0.00195. 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

Reg 5 is applied where the calculation happens, so net earnings are whole pence, which float32 can hold recoverably:

1. **`carers_allowance_earnings` applies reg 5 to each step.** It works in float64 pence from the whole pence each input was entered as (`policyengine_uk/utils/pence.py:stored_pence`).
   - Pay and profit are taken down to a whole penny (a higher figure never favours the claimant).
   - The calculated deductions are taken up to a whole penny, as reg 5 requires: notional tax, Class 4, Class 2, and half of pension contributions. That turns the half penny of an odd-pence contribution into a penny off earnings.
   - PAYE and Class 1 are in law the amounts actually deducted (reg 10(4)(a)), which are whole pence. The model's figures for them are rounded up the same way, in the claimant's favour.
   - **Class 1**, where the model calculates it with its own formula, is `ni_class_1_employee`'s formula worked 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 than `stored_pence` recovers: £175.56 (8% of £14,764.46 less £12,569.96) is stored as 175.5600128, and rounding that up would deduct a penny too much. The exact figure is used where it agrees with the stored total to within float32 error (one float32 step of the NI income plus 64 of the contribution); otherwise the stored months, summed exactly, are used. Where Class 1 is supplied as an input, for the year or month by month, or a reform calculates it, the formula deducts it as supplied or calculated, with its months summed exactly.
   - **Pension contributions** are `pension_contributions`, whether entered as a total or summed from its programmes. A float32 sum is redone in float64 pence (`stored_sum_pence`) where the stored total is within float32 rounding of its programmes' sum; otherwise it is read as entered. The model holds each programme as one annual amount, not the payments reg 10(4)(b) halves one by one, so the half is of the year's total, rounded up once.
2. **`carers_allowance_weekly_earnings_above_limit` reads the whole pence back exactly**, divides by 52 with the fraction disregarded (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). For a limit in whole pence, as every legislated and projected limit is, the excess is whole pence. An amount entered directly with a fraction of a penny has the fraction disregarded. The limit is compared in pence, rounded to a millionth of a penny, so a reformed limit of £204.10 isn't read as 20,409.999… pence.
3. **`is_gainfully_employed_for_carers_allowance`** keeps main's form: any excess means the carer is gainfully employed. An excess over a limit in whole pence is at least a penny, which float32 stores as a positive amount, and no excess is exactly zero. Under a reformed limit with a fraction of a penny, such as £204.105, the excess can be half a penny, which is still positive.
4. **The Budget 2026 earnings taper** (`carers_allowance_weekly_by_part_of_year` in `policyengine_uk/utils/carers_allowance_taper.py`) takes `taper.rate` (100%) of the excess off the weekly rate, in England and Wales, from 6 October 2030 in the model. It reads the excess with `stored_pence`, so it takes off exactly the whole pence above the limit (1p is stored as 0.0099999998), and because `stored_pence` returns 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.

```python
# carers_allowance_weekly_earnings_above_limit
annual_pence = np.floor(stored_pence(earnings))
weekly_pence = np.floor(annual_pence / WEEKS_IN_YEAR)
limit_pence = np.round(limit * 100, 6)
return max_(0, weekly_pence - limit_pence) / 100

# is_gainfully_employed_for_carers_allowance
return person("carers_allowance_weekly_earnings_above_limit", period) > 0

# the taper
excess = stored_pence(person("carers_allowance_weekly_earnings_above_limit", period)) / 100
tapered = np.isin(country, list(taper.countries)) * np.maximum(0, ca.rate - taper.rate * excess)
```

`stored_pence(x)` returns the whole pence a float32 lies within half a float32 step of, and otherwise its exact value in pence. An amount entered in whole pence is stored within half a step of them; below £131,072 a step is under a penny, so no other whole number of pence is that close. Every step is exact in float64 (a float32 has 24 significant bits, so × 100 is exact).

**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 tax is taken off, so whole-pence and fractional totals overlap. Applying reg 5 to each step, before storage, decides them exactly.

**Known limits:**

- **Inputs float32 can't tell apart.** An input within half a float32 step of a whole penny is read as that penny: £10,608.5195 is stored as the same float32 as £10,608.52.
- **Class 1 falls back to the stored months when the exact formula disagrees with them** beyond float32 error. A whole-penny amount the float32 sum holds more than half a step out then counts as a penny more.
- **An entered pension total within float32 rounding of its programmes' sum** (under 0.2p for a total of £10,000) is read as that sum.
- **Half of pension contributions is of the year's total**, rounded up once. A reading of "one half of any sum paid … in respect of a pay period" for a model that holds no payments.
- **The model's own approximations are unchanged.** PAYE is the tax on the pay alone on a standard code, as documented in the variable. The model's annual Class 1 primary threshold works out at £12,569.96 (pay of £12,601.00 gives Class 1 of £2.4832). This PR takes Class 1 as the model's formula gives it.
- **Above £131,072 a year**, whole pence may not be recovered exactly. That affects the decision only for limits above £2,520 a week; the highest statutory limit is £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 is off even where its sign is right. On main (46a60678f), 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 is 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 is 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_year` now 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_earnings` entered; before is main at 46a60678f)

| Year | Limit | Earnings | Weekly | Before | After |
|---|---|---|---|---|---|
| 2015–2019 | £110, £116, £120, £123 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged |
| 2020 | £128 | £6,656.52 | £128.01 | **not** gainfully employed, CA £3,497.00 | gainfully employed, CA nil |
| 2021 | £128 | £6,656.52 | £128.01 | **not**, CA £3,515.20 | gainfully employed, CA nil |
| 2022 | £132 | £6,864.52 | £132.01 | **not**, CA £3,624.40 | gainfully employed, CA nil |
| 2023 | £139 | £7,228.52 | £139.01 | **not**, CA £3,991.00 | gainfully employed, CA nil |
| 2024 | £151 | £7,852.52 | £151.01 | **not**, CA £4,258.80 | gainfully employed, CA nil |
| 2025–2026 | £196, £204 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged |
| 2027–2029 | £212, £217, £222 (projected) | 52L + 0.52 (entered as earnings) | L + £0.01 | gainfully employed, CA nil | unchanged |

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 is £2,500.44 for the year; from 2031-32 it is 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_earnings` itself moves by at most a few pence a year (each deduction's fraction of a penny).

**Employees whose Class 1 is a whole number of pence that the float32 sum holds out** are judged from the whole pence the law gives. An example is £14,764.46 of pay with a £7,960.76 contribution in 2026-27 (YAML Cases 26–28). They are exactly a penny a week over, 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`, through `carers_allowance_weekly_earnings_above_limit`;
- for the Scottish Carer Support Payment (2025-26 on), `carer_support_payment_pre_couple_limit`. Its own test, `earnings < (limit + £0.01) × 52` in float32, is exact on whole pence.

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 its boundary is now 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 is over the limit on both.

## Invariants

Stated in the test module docstrings, and executed:

1. **Whole pence are judged exactly:** annual earnings of A pence are over the limit L exactly when A // 52 > 100 × L, in every year from 2015 to the parameter horizon. Tested by `test_whole_pence_are_judged_exactly` (Hypothesis, with boundary examples for every #2541 limit) and the per-limit tests.
2. **Pay less half a pension contribution is judged as the law says:** pay of P pence with a contribution of C pence is over the limit exactly when (P − ceil(C/2)) // 52 > 100 × L. Tested by `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 earnings are exactly P − ceil(C/2)), and YAML Cases 23 and 24.
3. **Each deduction counts a fraction of a penny as a penny:** with tax and Class 1 or Class 4 to pay, net earnings are P less each deduction rounded up, exactly, and the limit test follows them. Class 1 is 8% of P less 52 × £241.73, worked exactly in the test, not read from the model. Tested by `test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny` (trading and employee, 2024-25 and 2026-27, with eight known boundary pairs) and YAML Cases 25–28.
4. **Exact arithmetic (differential):** for any stored amount, the float64 formula agrees with `_over_limit`, which restates it in `fractions.Fraction`. Tested by `test_the_formula_matches_exact_arithmetic_and_rises_with_earnings`, 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 through `test_bounds_and_differential` and the pension-share property test.
5. **Monotonic in earnings:** more earnings never take a person from over the limit to within it.
6. **Net earnings are monotonic in steps of 2p:** raising pay or profit by 2p or more never lowers them.
   - Why: the deductions that rise with pay (PAYE, Class 1) or with profit (notional tax, Class 4) have marginal rates adding to at most a half in 2026-27, and each is rounded up. So a rise of R pence adds less than R/2 + 2 pence of deductions, which is at most R when R ≥ 2.
   - **Intended violation:** a 1p rise can lower net earnings by 1p, when it takes both deductions to their next penny at once. £12,570.50 → £12,570.51 of profit with a £3,923.70 premium takes earnings from £10,608.52 (over) to £10,608.51 (within). That is reg 5 applied to each deduction, which the law requires.
   - Tested by `test_net_earnings_never_fall_for_a_rise_of_two_pence_or_more`, which runs 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 is exactly 1p. Pinned by `test_a_penny_more_profit_can_lower_net_earnings_by_a_penny` and YAML Cases 30–31.
7. **Entered pension totals:** `pension_contributions` entered as a total comes off earnings exactly as the same amount entered as a programme. Tested by `test_a_pension_total_entered_directly_counts_like_a_programme` and YAML Case 29.
8. **`stored_pence`** has four properties, tested in `test_stored_pence.py`:
   - every whole-pence amount below £131,072 round-trips exactly (exhaustive);
   - the result is never more than half a float32 step from the stored amount;
   - a fraction more than half a step from a whole penny is kept exactly;
   - it is monotonic.

   **`stored_sum_pence`** 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, as `adds` does (some of which `stored_pence` alone misses). It reads a total that is not such a sum as entered. Also YAML Case 32.
9. **Strict at the limit:** earnings of exactly 52 × L a year are within it, in every year.
10. The existing invariants (bounds, unearned income is not earnings, the pension-share exactness, within a penny of reg 5 rounding) still hold.

11. **The excess is exact:** annual earnings of A whole pence against a limit L have an excess of max(0, A // 52 − 100 × L) pence a week, recovered exactly from float32, and the carer is gainfully employed exactly when it is positive. With the taper applied by reform in every year from 2020-21, so that it reads every limit since then:
    - **the award is the rate less the excess**, 52 × max(0, 100 × R − E) pence a year, to within a float32 step of the annual award (the model's rates after the last legislated one are uprated projections, not whole pence);
    - **it never pays more than the full rate**, and neither does `carers_allowance_pre_couple_limit`, the award before the couple and overlap restrictions;
    - **it never goes negative**, and neither does `carers_allowance_pre_couple_limit`, from which `carers_allowance_pre_overlap` is built (`carers_allowance` floors its own result at zero again, so only this check catches a taper that goes negative);
    - **it falls by at most £1 for each £1 above the limit**: between any two carers, the weekly award differs by no more than their excesses;
    - **it is the full rate at or under the limit.**

    Tested by `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) and `test_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 #2541 limits as explicit examples).

## Tests

- **The taper at every limit** (2020 to 2041): `test_carers_allowance_weekly_earnings_above_limit_properties.py`, invariant 11.
- **Every limit, in every year** (2015 to 2041), with the taper switched off: `test_a_penny_a_week_over_each_limit_is_over_it_in_every_year` enters `carers_allowance_earnings` at 52L, 52L + £0.51 and 52L + £0.52. After the last legislated limit (2026-27) it takes the limit from the model, so the model's projected limits from 2027-28 (£212, rising to £309 from 2039-40) are covered. `test_pay_a_penny_a_week_over_each_limit_ends_entitlement` enters pay, from 2015 to 2026; it stops at 2026 because from 2029 `employment_income` subtracts `salary_sacrifice_broad_base_haircut`.
- **Odd-pence pension contributions** at every limit, 2015 to 2026, plus the Hypothesis property above.
- **Fractions of a penny in each deduction:** `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 is a whole number of pence that the float32 sum holds more than half a step out, each asserted to be exactly a penny a week over.
- **Monotonic net earnings** at every penny across each threshold, the 1p exception, and **entered pension totals**, as above.
- **Class 1 supplied or reformed:** Class 1 entered as an input (for the year or month by month) or calculated under a reform is deducted as entered or calculated, with its months summed exactly (`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_calculated` and their neighbours).
- `test_a_limit_in_pounds_and_pence_is_read_exactly`: a reformed limit of £204.10.
- **YAML Cases 21–37**, with expected values from the law: reg 8(1)'s £151 from 8 April 2024, £196 from 7 April 2025 and £204 from 6 April 2026; regs 5, 10(4) and 14; Class 1 at 8% above 52 × £241.73; and the CA rates of £81.90 and £86.45.
  - **21–24:** 2024-25 pay of £7,852.51 (within) and £7,852.52 (over), and £7,852.52 and £7,852.53 each with a 1p contribution (earnings £7,852.51, within; £7,852.52, over).
  - **25:** 2026-27 self-employed, £14,000.01 profit and a £6,039.38 premium (earnings £10,608.50, within, CA £4,495.40).
  - **26–28:** employees with a whole-penny Class 1 (£175.56 in 2026-27 and 2025-26, £43.80 in 2024-25), each a penny a week over.
  - **29:** a £1,000 pension total entered directly (within).
  - **30–31:** the 1p pair.
  - **32:** two pension programmes whose float32 sum is out by 0.0137p, more than half a step (over).
  - **33–37:** Class 1 entered as nil, and for the year in whole pence, is deducted as entered; a pension total, pay, and both, summed by the model from two inputs, are read in whole pence.
- **CSP YAML:** two cases at the per-step boundary in Scotland, 2026-27 (£12,576.76 within, CSP £4,495.40; £12,576.77 over, CSP nil).

**Each wrong rule in the excess and the taper fails a test.** Each mutation below was made to the formula at this head, everything else unchanged, and run against the new taper module and the two every-limit tests (`test_a_penny_a_week_over_each_limit_is_over_it_in_every_year` and `test_pay_a_penny_a_week_over_each_limit_ends_entitlement`), 25 tests:

| Mutation | Result |
|---|---|
| M1: main's float32 `carers_allowance_weekly_earnings_above_limit` | killed, 25 failed (all 23 taper tests and both every-limit tests) |
| M2: excess as `np.float32(weekly_pence / 100) - np.float32(limit)` | killed, 23 failed |
| M3: the taper not floored at zero (`np.maximum(0, …)` removed) | killed, 1 failed: `test_the_taper_follows_exact_pence`, at `pre_couple >= 0` |
| M4: the taper takes 101% of the excess | killed, 23 failed |
| M5: weekly pence not floored (reg 5's fraction kept) | killed, 25 failed |
| M6: the taper reads the excess in whole pounds | killed, 23 failed |
| M7: the taper reads the float32 excess without `stored_pence` | killed, 7 failed (2021, 2022, 2025, 2039, 2040 and 2041 of the per-year test, and `test_the_taper_follows_exact_pence`) |
| M8: the excess counted from the limit (+1p, so `>=`) | killed, 25 failed |

## Local runs at 32093c6cf

Python 3.13, `uv sync --frozen --extra dev`, `-n 4`:

- `pytest` on `test_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 reads `carers_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.py` and `test_stored_pence.py`: **179 passed**.
- `policyengine-core test` on the 22 YAML files under `policyengine_uk/tests/policy` whose names match `carer` or `care_hours`: **308 passed**; on the 72 that mention `carer` or `care_hours` (a superset): **1,318 passed**.
- `pytest policyengine_uk/tests/code_health`: **3,088 passed**.
- Ruff 0.17.2 `format --check .`, `check .`, `check --select F811,F821 policyengine_uk`, and `git diff --check`: clean.
- `.github/check_fix_pr.py` on this title and body: **PASS** (6 changed executable tests).
- Main at 46a60678f merges cleanly (`git merge-tree --write-tree`). On that merge, not committed, the same pytest files give 179 passed, the YAML 308 and 1,318 passed, and `code_health` 3,090 passed.

## Aggregate impact

**At 32093c6cf: pending** (CI has not run on this head).

CI's Aggregate impact report at an earlier head, 32f163910, before the merge with main and so without the taper ([run 40589857208](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40589857208)): base `6030cbc0ba`, which is main and the first parent of the merge commit `c95ea031b0` (its second parent was that head). Both sides ran on `enhanced_frs_2024_25.h5` (policyengine-uk-data 1.58.1), fiscal years 2025-26 to 2031-32, with a full microsimulation each year on each side. **No household's net income changed in any year.** No receipts, spending, caseload, poverty, inequality or decile figure moved. Checks: 0 broken, 0 fixed, 1 failing on both sides (VAT sign, unrelated). No record in the survey data lies close enough to a boundary to change; the fix matters for individual carers a penny a week over the limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head: 32093c6cff83e8a3c12ed7df53181463de89aecc on claude/carers-allowance-review-findings-8qvji6, a new branch from c4b6335160c813f3326f6828790031e23ce0c252. It has two commits and no pull request. I didn't push to fix/2541-ca-earnings-pence, which still sits at 2502953bf, or edit the live PR. The new title and body are the two comments above.

Findings

P2-1, the taper's stored_pence read. policyengine_uk/utils/carers_allowance_taper.py:66-68 now carries the review's comment: the read returns float64, so the subtraction runs in float64. Body rule item 4 says that without the read the subtraction runs in float32 and seven tests fail, and gives the 2021 award (351,467.96875p against 351,468p, from my M7 log). Nothing in the code, body or changelog says a test can't detect the read (git grep).

P2-2, the taper's floor. _run in test_carers_allowance_weekly_earnings_above_limit_properties.py:99-125 also returns carers_allowance_pre_couple_limit in pence. test_the_taper_follows_exact_pence asserts pre_couple <= WEEKS * rate + WEEKS * slack and pre_couple >= 0 (lines 206-207), beside the award checks, and the module docstring's invariants 3 and 4 name it. I removed np.maximum(0, …) in a scratch copy (M3): killed, 1 failed (test_the_taper_follows_exact_pence, at assert (pre_couple >= 0).all()).

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.

  • is_gainfully_employed_for_carers_allowance.py:36-38 now has the review's comment.
  • The documentation of carers_allowance_weekly_earnings_above_limit now says "in whole pence for a limit in whole pence".
  • I reran the £204.105 reform with 52 × £204.11 of earnings: excess 0.004999999888241291, flag True.
  • Body rule items 2 and 3 say the same.

P3-3, the changelog (changelog.d/2541.fixed.md), two sentences. I checked each claim:

  • The four limits match earnings_limit.yaml and the parameter tree for 2020-2024.
  • "Budget 2026 earnings taper": the taper's parameter descriptions and references name Budget 2026 (earnings_taper/index.yaml), and the code comment cites Budget 2026 para 6.13.
  • CSP reads carers_allowance_earnings (carer_support_payment_pre_couple_limit.py:35).
  • The review's "taking slightly less than the pence above the limit off the award" was vague, so I measured it. I ran 10,001 carers, 0p to £100 a week over the 2031-32 limit, on main and at this head. Main pays 1,700 of them £0.52 a year (a penny a week) more than the rate less the excess. This head is within a float32 step (0.000488) for all of them. The fragment now says "taking a penny a week less than the pence above the limit off some awards".
  • "In whole pence" is qualified "for a limit in whole pence" (P3-2).

P3-4, process text.

  • The documentation sentence is replaced: "The formula spreads annual earnings evenly over 52 weeks to stand for the earnings of the preceding week (ICA Regulations reg. 8(1))", folded into the sentence that follows.
  • I removed every mention of a review, a salvage, a probe script and ~/reviews/…/r5/ paths from the body. The "Review findings addressed" section is gone.
  • Writing rules also cover code comments, so I took the same text out of the test comments. I renamed REVIEW_PAIRS to KNOWN_PAIRS (test_carers_allowance_earnings_limit_properties.py:499-506), dropped "(found in review)" from four docstrings, and removed a reviewer's name from two comments (:696 and YAML Case 25). The changes are comments and the rename only.

Counts cut because no repository test reproduces them and I couldn't rerun them (the scripts aren't in this session):

  • sweep_exhaustive.py sets A–C (16.8 million float32 values, and 0 / 1-per-limit / 2–5 wrong).
  • sweep_offgrid.py (0 / 455 / 609 trading, 0 / 124 / 214 employee). Also the "about 140 trading and 31 employee rows" and "124 rather than 115" / "214 rather than 219".
  • sweep_class1_exact.py (5,248 rows a year; 0 / 6 / 580).
  • sweep_pension_pairs.py (59,532 pairs, 0 wrong).
  • class1_tolerance_study.py (54,000 pays a year, 0.42p, 0.18 of the tolerance).
  • "39 of 1,779 traders" misjudged by a two-step tolerance.
  • The swap-check table (r5/swap_checks.sh, r5/yaml_swaps.sh), replaced by my M1–M8 table at this head.
  • "32f163910 judged them within in all three years" and "32f163910 had dropped them": claims about an old head I didn't rerun.

Counts I kept, each reproduced by a test or rerun:

  • 1,210 pairs plus eight known pairs (_with_fractions and KNOWN_PAIRS, counted).
  • The effect-table awards (rerun on main).
  • £10,608.5174 (the YAML Case 25 comment's arithmetic).

Two body claims were wrong at current main and are corrected:

  • The body said main judged the 2024-25 Class 1 employee (Case 28) within. Main judges all three cases over (£10,608.5205, £10,192.5205 and £7,852.5254).
  • The body gave an award of £2,500.42 on main for 2030-31, which I had drafted. Main gives £2,500.44, the same as this head.

The CSP figure (main £10,608.5225, CSP nil) checks out on main.

Invariant 8 said stored_sum_pence recovers "2 million sums of up to five amounts". test_sums_of_whole_pence_are_recovered draws 200,000 sums for each of five shapes, up to seven amounts in groups of up to five. The body now says "a million … in groups of up to five summed and then added".

P3-5. I read carers_allowance_earnings.py:114-209.

  • _class_1_calculated returns False for an input supplied for any month or the year, or for a reform that changes a formula, its dates, how it's summed, neutralisation or a read. _primary_class_1 then returns amount_pence, whose months months_summed sums exactly.
  • So the review's sentence holds, and I added it to rule item 1.
  • I also added what the code does when the model's formula ran but disagrees beyond tolerance: where(agrees, exact, deducted) uses the stored months, summed exactly. The "Known limits" Class 1 bullet now says "stored months", not "stored total".

Main. git merge-tree --write-tree 32093c6 46a60678f merges cleanly (tree 97f745918). Main's changes since 2da212abc touch no Carer's Allowance, pence or shard file. I tested the merge in a scratch worktree, uncommitted: pytest 179 passed, YAML 308 and 1,318 passed, code_health 3,090 passed. I didn't merge main into the branch, because nothing conflicted or failed.

Tests at 32093c6

Check Result
pytest, the seven files in the brief, -n 4 (uv sync --frozen --extra dev, Python 3.13) 179 passed
YAML under tests/policy whose names match carer or care_hours (22 files) 308 passed
YAML that mention carer or care_hours (72 files, the set behind the body's 1,318) 1,318 passed
pytest policyengine_uk/tests/code_health -n 4 3,088 passed
.github/check_fix_pr.py, the final title and body, --head-sha 32093c6, changes against main PASS, 6 changed executable tests
Ruff 0.17.2 format --check . (2,167 files), check ., check --select F811,F821 policyengine_uk; git diff --check 2da212abc HEAD clean

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.

Mutant Result
M1 main's float32 variable killed, 25 failed
M2 np.float32(weekly_pence/100) - np.float32(limit) killed, 23 failed
M3 taper floor removed killed, 1 failed (was survived)
M4 101% taper killed, 23 failed
M5 weekly pence not floored killed, 25 failed
M6 excess in whole pounds killed, 23 failed
M7 no stored_pence in the taper killed, 7 failed (2021, 2022, 2025, 2039, 2040, 2041, and the Hypothesis test)
M8 >= (+1p) killed, 25 failed

Skipped, and judgement calls

  • Skipped: aggregate impact and any survey-data test (no dataset here). The body marks aggregate impact at this head as pending.
  • Judgement call: the invariant numbers don't match. The body's invariant list and the docstring numbering in test_carers_allowance_earnings_limit_properties.py differ: the module's invariants 11–13 are about Class 1 and sums, while the body's 11 is the excess. I left the numbering alone, because changing it was outside these findings.
  • Judgement call: YAML Cases 33–37. The body listed Cases 21–32, but the file goes to Case 37. I added a line for 33–37 from their names.
  • Judgement call: Class 1 tests. I added a Tests bullet naming the Class 1 supplied and reformed tests, which the code has and the body didn't describe.
  • Judgement call: the PR title is unchanged (52 characters).
  • Judgement call: the 2030-31 sentence. I kept the review's sentence and added the rerun figures and the full-year scope of invariant 11, as P2-3 explains.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

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
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_TITLE.txt

Count Carer's Allowance earnings-limit pence exactly

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_BODY.md

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_exactly`
axiom: https://github.com/TheAxiomFoundation/rulespec-uk/issues/482 queued

## What and why

`carers_allowance_weekly_earnings_above_limit` gives how far a carer's weekly earnings exceed the Carer's Allowance earnings limit, and `is_gainfully_employed_for_carers_allowance` asks 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:

- **Reg 5** says that where "any calculation under these Regulations results in a fraction of a penny", the fraction "shall, if it would be to the claimant's advantage, be treated as a penny, otherwise it shall be disregarded".
- **Reg 8(1)(b)(iii)** turns a yearly payment into a weekly one "by dividing the amount of the payment by 52".
- **Reg 10(4)** nets PAYE, primary Class 1 and "one half of any sum paid … by way of a contribution towards an occupational or personal pension scheme" off pay. **Regs 13 and 14** net a notional basic-rate income tax, Class 4 (and, before 2024-25, Class 2) and half of pension premiums off trading profit.

On main, `carers_allowance_weekly_earnings_above_limit` floors the weekly pence in float32, the dtype of `carers_allowance_earnings`: `np.floor(weekly * 100 + 1e-6) / 100`, less the limit. At these magnitudes the `+ 1e-6` does nothing, because float32 spacing near 20,400 is 0.00195. 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

Reg 5 is applied where the calculation happens, so net earnings are whole pence, which float32 can hold recoverably:

1. **`carers_allowance_earnings` applies reg 5 to each step.** It works in float64 pence from the whole pence each input was entered as (`policyengine_uk/utils/pence.py:stored_pence`).
   - Pay and profit are taken down to a whole penny (a higher figure never favours the claimant).
   - The calculated deductions are taken up to a whole penny, as reg 5 requires: notional tax, Class 4, Class 2, and half of pension contributions. That turns the half penny of an odd-pence contribution into a penny off earnings.
   - PAYE and Class 1 are in law the amounts actually deducted (reg 10(4)(a)), which are whole pence. The model's figures for them are rounded up the same way, in the claimant's favour.
   - **Class 1**, where the model calculates it with its own formula, is `ni_class_1_employee`'s formula worked 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 than `stored_pence` recovers: £175.56 (8% of £14,764.46 less £12,569.96) is stored as 175.5600128, and rounding that up would deduct a penny too much. The exact figure is used where it agrees with the stored total to within float32 error (one float32 step of the NI income plus 64 of the contribution); otherwise the stored months, summed exactly, are used. Where Class 1 is supplied as an input, for the year or month by month, or a reform calculates it, the formula deducts it as supplied or calculated, with its months summed exactly.
   - **Pension contributions** are `pension_contributions`, whether entered as a total or summed from its programmes. Where the model sums them, `amount_pence` redoes the float32 sum in float64 pence: it reads each programme with `stored` and adds their pence with `summed`, where the stored total is within float32 rounding of its programmes' sum; otherwise it 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 half is of the year's total, rounded up once.
2. **`carers_allowance_weekly_earnings_above_limit` reads the whole pence back exactly**, divides by 52 with the fraction disregarded (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). For a limit in whole pence, as every legislated and projected limit is, the excess is whole pence. An amount entered directly with a fraction of a penny has the fraction disregarded. The limit is compared in pence, rounded to a millionth of a penny, so a reformed limit of £204.10 isn't read as 20,409.999… pence.
3. **`is_gainfully_employed_for_carers_allowance`** keeps main's form: any excess means the carer is gainfully employed. An excess over a limit in whole pence is at least a penny, which float32 stores as a positive amount, and no excess is exactly zero. Under a reformed limit with a fraction of a penny, such as £204.105, the excess can be half a penny, which is still positive.
4. **The Budget 2026 earnings taper** (`carers_allowance_weekly_by_part_of_year` in `policyengine_uk/utils/carers_allowance_taper.py`) takes `taper.rate` (100%) of the excess off the weekly rate, in England and Wales, from 6 October 2030 in the model. It reads the excess with `stored_pence`, so it takes off exactly the whole pence above the limit (1p is stored as 0.0099999998), and because `stored_pence` returns 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.

```python
# carers_allowance_weekly_earnings_above_limit
annual_pence = np.floor(stored_pence(earnings))
weekly_pence = np.floor(annual_pence / WEEKS_IN_YEAR)
limit_pence = np.round(limit * 100, 6)
return max_(0, weekly_pence - limit_pence) / 100

# is_gainfully_employed_for_carers_allowance
return person("carers_allowance_weekly_earnings_above_limit", period) > 0

# the taper
excess = stored_pence(person("carers_allowance_weekly_earnings_above_limit", period)) / 100
tapered = np.isin(country, list(taper.countries)) * np.maximum(0, ca.rate - taper.rate * excess)
```

`stored_pence(x)` returns the whole pence a float32 lies within half a float32 step of, and otherwise its exact value in pence. An amount entered in whole pence is stored within half a step of them; below £131,072 a step is under a penny, so no other whole number of pence is that close. Every step is exact in float64 (a float32 has 24 significant bits, so × 100 is exact).

**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 tax is taken off, so whole-pence and fractional totals overlap. Applying reg 5 to each step, before storage, decides them exactly.

**Known limits:**

- **Inputs float32 can't tell apart.** An input within half a float32 step of a whole penny is read as that penny: £10,608.5195 is stored as the same float32 as £10,608.52.
- **Class 1 falls back to the stored months when the exact formula disagrees with them** beyond float32 error. A whole-penny amount the float32 sum holds more than half a step out then counts as a penny more.
- **An entered pension total within float32 rounding of its programmes' sum** (under 0.2p for a total of £10,000) is read as that sum.
- **Half of pension contributions is of the year's total**, rounded up once. A reading of "one half of any sum paid … in respect of a pay period" for a model that holds no payments.
- **The model's own approximations are unchanged.** PAYE is the tax on the pay alone on a standard code, as documented in the variable. The model's annual Class 1 primary threshold works out at £12,569.96 (pay of £12,601.00 gives Class 1 of £2.4832). This PR takes Class 1 as the model's formula gives it.
- **Above £131,072 a year**, whole pence may not be recovered exactly. That affects the decision only for limits above £2,520 a week; the highest statutory limit is £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 is off even where its sign is right. On main (46a60678f), 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 is 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 is 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_year` now 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_earnings` entered; before is main at 46a60678f)

| Year | Limit | Earnings | Weekly | Before | After |
|---|---|---|---|---|---|
| 2015–2019 | £110, £116, £120, £123 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged |
| 2020 | £128 | £6,656.52 | £128.01 | **not** gainfully employed, CA £3,497.00 | gainfully employed, CA nil |
| 2021 | £128 | £6,656.52 | £128.01 | **not**, CA £3,515.20 | gainfully employed, CA nil |
| 2022 | £132 | £6,864.52 | £132.01 | **not**, CA £3,624.40 | gainfully employed, CA nil |
| 2023 | £139 | £7,228.52 | £139.01 | **not**, CA £3,991.00 | gainfully employed, CA nil |
| 2024 | £151 | £7,852.52 | £151.01 | **not**, CA £4,258.80 | gainfully employed, CA nil |
| 2025–2026 | £196, £204 | 52L + 0.52 | L + £0.01 | gainfully employed, CA nil | unchanged |
| 2027–2029 | £212, £217, £222 (projected) | 52L + 0.52 (entered as earnings) | L + £0.01 | gainfully employed, CA nil | unchanged |

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 is £2,500.44 for the year; from 2031-32 it is 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_earnings` itself moves by at most a few pence a year (each deduction's fraction of a penny).

**Employees whose Class 1 is a whole number of pence that the float32 sum holds out** are judged from the whole pence the law gives. An example is £14,764.46 of pay with a £7,960.76 contribution in 2026-27 (YAML Cases 26–28). They are exactly a penny a week over, 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`, through `carers_allowance_weekly_earnings_above_limit`;
- for the Scottish Carer Support Payment (2025-26 on), `carer_support_payment_pre_couple_limit`. Its own test, `earnings < (limit + £0.01) × 52` in float32, is exact on whole pence.

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 its boundary is now 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 is over the limit on both.

## Invariants

Stated in the test module docstrings, and executed:

1. **Whole pence are judged exactly:** annual earnings of A pence are over the limit L exactly when A // 52 > 100 × L, in every year from 2015 to the parameter horizon. Tested by `test_whole_pence_are_judged_exactly` (Hypothesis, with boundary examples for every #2541 limit) and the per-limit tests.
2. **Pay less half a pension contribution is judged as the law says:** pay of P pence with a contribution of C pence is over the limit exactly when (P − ceil(C/2)) // 52 > 100 × L. Tested by `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 earnings are exactly P − ceil(C/2)), and YAML Cases 23 and 24.
3. **Each deduction counts a fraction of a penny as a penny:** with tax and Class 1 or Class 4 to pay, net earnings are P less each deduction rounded up, exactly, and the limit test follows them. Class 1 is 8% of P less 52 × £241.73, worked exactly in the test, not read from the model. Tested by `test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny` (trading and employee, 2024-25 and 2026-27, with eight known boundary pairs) and YAML Cases 25–28.
4. **Exact arithmetic (differential):** for any stored amount, the float64 formula agrees with `_over_limit`, which restates it in `fractions.Fraction`. Tested by `test_the_formula_matches_exact_arithmetic_and_rises_with_earnings`, 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 through `test_bounds_and_differential` and the pension-share property test.
5. **Monotonic in earnings:** more earnings never take a person from over the limit to within it.
6. **Net earnings are monotonic in steps of 2p:** raising pay or profit by 2p or more never lowers them.
   - Why: the deductions that rise with pay (PAYE, Class 1) or with profit (notional tax, Class 4) have marginal rates adding to at most a half in 2026-27, and each is rounded up. So a rise of R pence adds less than R/2 + 2 pence of deductions, which is at most R when R ≥ 2.
   - **Intended violation:** a 1p rise can lower net earnings by 1p, when it takes both deductions to their next penny at once. £12,570.50 → £12,570.51 of profit with a £3,923.70 premium takes earnings from £10,608.52 (over) to £10,608.51 (within). That is reg 5 applied to each deduction, which the law requires.
   - Tested by `test_net_earnings_never_fall_for_a_rise_of_two_pence_or_more`, which runs 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 is exactly 1p. Pinned by `test_a_penny_more_profit_can_lower_net_earnings_by_a_penny` and YAML Cases 30–31.
7. **Entered pension totals:** `pension_contributions` entered as a total comes off earnings exactly as the same amount entered as a programme. Tested by `test_a_pension_total_entered_directly_counts_like_a_programme` and YAML Case 29.
8. **`stored_pence`** has four properties, tested in `test_stored_pence.py`:
   - every whole-pence amount below £131,072 round-trips exactly (exhaustive);
   - the result is never more than half a float32 step from the stored amount;
   - a fraction more than half a step from a whole penny is kept exactly;
   - it is monotonic.

   **`summed`**, which `amount_pence` uses 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, as `adds` does (some of which `stored_pence` alone misses). It reads a total that is not such a sum as entered. Also YAML Case 32. **`months_summed`**, which `amount_pence` uses for a monthly flow over a year, recovers every whole-pence amount below £32,768 entered for a year and divided among its months (exhaustive).

   **Zero amounts don't underflow:** `stored_pence`, `summed` and `months_summed` read zero amounts, whose bound is zero, as 0 pence and raise no warning when numpy warns on every floating-point error and warnings are errors. `summed` and `months_summed` give the same `Pence` under that state as under numpy's default, for float32 amounts in whole pence from −£131,072 to £131,072. The step above a zero bound is the smallest subnormal float32, which numpy reports as underflow, so `summed` and `_step` work the step out under `np.errstate(under="ignore")` and leave numpy's error state as it was. Tested by `test_zero_amounts_read_as_zero_pence_without_a_warning` and `test_zero_bounds_read_the_same_under_a_strict_error_state` (Hypothesis), and by `test_the_divisions_the_filters_hid_raise_no_runtime_warning` in `test_warning_filters.py`, which runs the model under that state.
9. **Strict at the limit:** earnings of exactly 52 × L a year are within it, in every year.
10. The existing invariants (bounds, unearned income is not earnings, the pension-share exactness, within a penny of reg 5 rounding) still hold.

11. **The excess is exact:** annual earnings of A whole pence against a limit L have an excess of max(0, A // 52 − 100 × L) pence a week, recovered exactly from float32, and the carer is gainfully employed exactly when it is positive. With the taper applied by reform in every year from 2020-21, so that it reads every limit since then:
    - **the award is the rate less the excess**, 52 × max(0, 100 × R − E) pence a year, to within a float32 step of the annual award (the model's rates after the last legislated one are uprated projections, not whole pence);
    - **it never pays more than the full rate**, and neither does `carers_allowance_pre_couple_limit`, the award before the couple and overlap restrictions;
    - **it never goes negative**, and neither does `carers_allowance_pre_couple_limit`, from which `carers_allowance_pre_overlap` is built (`carers_allowance` floors its own result at zero again, so only this check catches a taper that goes negative);
    - **it falls by at most £1 for each £1 above the limit**: between any two carers, the weekly award differs by no more than their excesses;
    - **it is the full rate at or under the limit.**

    Tested by `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) and `test_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 #2541 limits as explicit examples).

## Tests

- **The taper at every limit** (2020 to 2041): `test_carers_allowance_weekly_earnings_above_limit_properties.py`, invariant 11.
- **Every limit, in every year** (2015 to 2041), with the taper switched off: `test_a_penny_a_week_over_each_limit_is_over_it_in_every_year` enters `carers_allowance_earnings` at 52L, 52L + £0.51 and 52L + £0.52. After the last legislated limit (2026-27) it takes the limit from the model, so the model's projected limits from 2027-28 (£212, rising to £309 from 2039-40) are covered. `test_pay_a_penny_a_week_over_each_limit_ends_entitlement` enters pay, from 2015 to 2026; it stops at 2026 because from 2029 `employment_income` subtracts `salary_sacrifice_broad_base_haircut`.
- **Odd-pence pension contributions** at every limit, 2015 to 2026, plus the Hypothesis property above.
- **Fractions of a penny in each deduction:** `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 is a whole number of pence that the float32 sum holds more than half a step out, each asserted to be exactly a penny a week over.
- **Monotonic net earnings** at every penny across each threshold, the 1p exception, and **entered pension totals**, as above.
- **Class 1 supplied or reformed:** Class 1 entered as an input (for the year or month by month) or calculated under a reform is deducted as entered or calculated, with its months summed exactly (`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_calculated` and their neighbours).
- `test_a_limit_in_pounds_and_pence_is_read_exactly`: a reformed limit of £204.10.
- **YAML Cases 21–37**, with expected values from the law: reg 8(1)'s £151 from 8 April 2024, £196 from 7 April 2025 and £204 from 6 April 2026; regs 5, 10(4) and 14; Class 1 at 8% above 52 × £241.73; and the CA rates of £81.90 and £86.45.
  - **21–24:** 2024-25 pay of £7,852.51 (within) and £7,852.52 (over), and £7,852.52 and £7,852.53 each with a 1p contribution (earnings £7,852.51, within; £7,852.52, over).
  - **25:** 2026-27 self-employed, £14,000.01 profit and a £6,039.38 premium (earnings £10,608.50, within, CA £4,495.40).
  - **26–28:** employees with a whole-penny Class 1 (£175.56 in 2026-27 and 2025-26, £43.80 in 2024-25), each a penny a week over.
  - **29:** a £1,000 pension total entered directly (within).
  - **30–31:** the 1p pair.
  - **32:** two pension programmes whose float32 sum is out by 0.0137p, more than half a step (over).
  - **33–37:** Class 1 entered as nil, and for the year in whole pence, is deducted as entered; a pension total, pay, and both, summed by the model from two inputs, are read in whole pence.
- **CSP YAML:** two cases at the per-step boundary in Scotland, 2026-27 (£12,576.76 within, CSP £4,495.40; £12,576.77 over, CSP nil).
- **Zero amounts under a strict numpy error state:** two tests in `test_stored_pence.py` (invariant 8). With `summed` and `_step` as they were at 32093c6cf, without `np.errstate(under="ignore")`, both fail with `RuntimeWarning: underflow encountered in nextafter`.

**Each wrong rule in the excess and the taper fails a test.** Each mutation below was made to the formula at this head, everything else unchanged, and run against the new taper module and the two every-limit tests (`test_a_penny_a_week_over_each_limit_is_over_it_in_every_year` and `test_pay_a_penny_a_week_over_each_limit_ends_entitlement`), 25 tests:

| Mutation | Result |
|---|---|
| M1: main's float32 `carers_allowance_weekly_earnings_above_limit` | killed, 25 failed (all 23 taper tests and both every-limit tests) |
| M2: excess as `np.float32(weekly_pence / 100) - np.float32(limit)` | killed, 23 failed |
| M3: the taper not floored at zero (`np.maximum(0, …)` removed) | killed, 1 failed: `test_the_taper_follows_exact_pence`, at `pre_couple >= 0` |
| M4: the taper takes 101% of the excess | killed, 23 failed |
| M5: weekly pence not floored (reg 5's fraction kept) | killed, 25 failed |
| M6: the taper reads the excess in whole pounds | killed, 23 failed |
| M7: the taper reads the float32 excess without `stored_pence` | killed, 7 failed (2021, 2022, 2025, 2039, 2040 and 2041 of the per-year test, and `test_the_taper_follows_exact_pence`) |
| M8: the excess counted from the limit (+1p, so `>=`) | killed, 25 failed |

## Local runs at df7893033

Python 3.13, `uv sync --frozen --extra dev`, `-n 4`:

- `pytest` on `test_warning_filters.py`: **32 passed** (at 32093c6cf both cases of `test_the_divisions_the_filters_hid_raise_no_runtime_warning` fail with `RuntimeWarning: underflow encountered in nextafter`; on main at 6c6e229a5 and at 0d100df2f both pass).
- `pytest` on `test_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) and `test_stored_pence.py` (11), each file on its own: **181 passed**.
- `policyengine-core test` on `carers_allowance_earnings_limit.yaml`: **37 passed**; on `carer_support_payment_earnings_limit.yaml`: **19 passed**; on the 22 YAML files whose names match `carer` or `care_hours`: **308 passed**; on the 72 that mention them: **1,318 passed**.
- `pytest policyengine_uk/tests/code_health`: **3,088 passed**.
- Ruff 0.17.2 `format --check .` (2,167 files), `check .`, `check --select F811,F821 policyengine_uk`, and `git diff --check 32093c6cf..HEAD`: clean.
- `.github/check_fix_pr.py` on this title and body, with the changes from its merge base with main: **PASS** (6 changed executable tests).
- Main at 0080274d7 merges cleanly (`git merge-tree --write-tree`), as does 6c6e229a5. On that merge, not committed, `test_warning_filters.py` and `test_stored_pence.py` give 43 passed.

## Local runs at 32093c6cf

Python 3.13, `uv sync --frozen --extra dev`, `-n 4`:

- `pytest` on `test_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 reads `carers_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.py` and `test_stored_pence.py`: **179 passed**.
- `policyengine-core test` on the 22 YAML files under `policyengine_uk/tests/policy` whose names match `carer` or `care_hours`: **308 passed**; on the 72 that mention `carer` or `care_hours` (a superset): **1,318 passed**.
- `pytest policyengine_uk/tests/code_health`: **3,088 passed**.
- Ruff 0.17.2 `format --check .`, `check .`, `check --select F811,F821 policyengine_uk`, and `git diff --check`: clean.
- `.github/check_fix_pr.py` on this title and body: **PASS** (6 changed executable tests).
- Main at 46a60678f merges cleanly (`git merge-tree --write-tree`). On that merge, not committed, the same pytest files give 179 passed, the YAML 308 and 1,318 passed, and `code_health` 3,090 passed.

## Aggregate impact

**At df7893033: pending** (CI has not run on this head).

CI's Aggregate impact report at an earlier head, 32f163910, before the merge with main and so without the taper ([run 40589857208](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40589857208)): base `6030cbc0ba`, which is main and the first parent of the merge commit `c95ea031b0` (its second parent was that head). Both sides ran on `enhanced_frs_2024_25.h5` (policyengine-uk-data 1.58.1), fiscal years 2025-26 to 2031-32, with a full microsimulation each year on each side. **No household's net income changed in any year.** No receipts, spending, caseload, poverty, inequality or decile figure moved. Checks: 0 broken, 0 fixed, 1 failing on both sides (VAT sign, unrelated). No record in the survey data lies close enough to a boundary to change; the fix matters for individual carers a penny a week over the limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head: df789303330cab139347e47f7bbad1d1ea6e75f4 on claude/pence-helpers-underflow-warning-4y6ky1, a new branch from 32093c6cff83e8a3c12ed7df53181463de89aecc. It has one commit and no pull request. I didn't push to fix/2541-ca-earnings-pence, which still sits at 32093c6cf, and didn't edit the live PR. The new title (unchanged) and body are the two comments above.

Findings

1. Reproduced. test_warning_filters.py::test_the_divisions_the_filters_hid_raise_no_runtime_warning:

  • at 32093c6cf: [baseline] and [reform] both fail, RuntimeWarning: underflow encountered in nextafter at pence.py:82;
  • on main at 6c6e229a5 (the brief's SHA) and at 0d100df2f (main when I started): both pass, run from scratch worktrees.

2. Which step computations underflow. Each called on zero amounts and zero bounds under warnings.catch_warnings() + simplefilter("error") + np.errstate(all="warn"), at 32093c6cf:

Site Result
pence.py:35 stored_pence (np.spacing(0)) no warning, returns 0
pence.py:82-85 summed (np.nextafter(0, inf)) raises underflow
pence.py:164-165 _step (np.nextafter), so months_summed raises underflow
carers_allowance_earnings.py:205-206 (np.spacing of zero NI income and zero Class 1) no warning

Only np.nextafter from zero flags underflow; np.spacing(0) and np.spacing of the subnormal it returns don't. I kept pence.py:35 and carers_allowance_earnings.py:205-206 as they are.

3. The fix (policyengine_uk/utils/pence.py:82-88 in summed, :167-171 in _step). Each wraps only the step computation (nextafter and its spacing) in with np.errstate(under="ignore"):, with the comment "The step above zero is the smallest subnormal float32, which numpy reports as underflow; that step still bounds the rounding." The values don't change: _step(0) is still 1.4e-45 and the summed tolerance is still the same subnormal times count. No other change to the model.

4. Tests (policyengine_uk/tests/test_stored_pence.py, module invariant 11):

  • test_zero_amounts_read_as_zero_pence_without_a_warning: stored_pence, stored, summed (two zero parts) and months_summed (twelve zero months) under the strict state give 0 pence, bound 0, and count 1, 2 and 12, the values they give today under numpy's default.
  • test_zero_bounds_read_the_same_under_a_strict_error_state (Hypothesis, 300 examples, derandomized; zero drawn as one of two branches, plus explicit all-zero and [1, -1] examples): float32 whole pence from −£131,072 to £131,072, 1–5 parts and 12 months; summed and months_summed give identical Pence under the strict state and numpy's default (divide/over/invalid="warn", under="ignore"), and raise nothing.
  • Revert check: with pence.py from 32093c6cf in a scratch worktree, both new tests fail, each with RuntimeWarning: underflow encountered in nextafter. Restored afterwards.
  • test_warning_filters.py whole at my head: 32 passed.

5. The body.

  • Rule item 1 (pension contributions) now names amount_pence, stored and summed in place of stored_sum_pence.
  • Invariant 8 names summed in place of stored_sum_pence, adds months_summed (its exhaustive year-divided-among-months test), and adds the zero-bound property with its two tests and test_warning_filters.py.
  • Tests list gains a bullet for the zero-amount tests and the revert check.
  • New "Local runs at df78930" list; the 32093c6 list is kept under its label. Aggregate impact at df78930: pending.
  • I checked every backticked name in the body with git grep / git ls-files at my head. Only stored_sum_pence was stale. c95ea031b0 isn't in the repository, because it's CI's merge commit, cited as such.
  • Changelog fragment unchanged.

Tests at df78930 (uv sync --frozen --extra dev, Python 3.13, -n 4)

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, summed and _step, so each block has the same one-line comment.
  • Judgement call: np.spacing stays inside the errstate block with nextafter, because the brief treats :82-85 and :164-165 as one step computation each. np.spacing alone didn't warn.
  • Judgement call: months_summed in 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.toml for the two new tests, which are fast; code_health passes.
  • Title unchanged. The Reproducer: and axiom: lines are unchanged.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Verdict: APPROVE WITH NITS at df789303330cab139347e47f7bbad1d1ea6e75f4 (fix/2541-ca-earnings-pence)

I reviewed git diff c4b633516..df7893033: three commits (abeb50588, 32093c6cf, df7893033) and nine files. I checked the head out detached in a scratch worktree, ran uv sync --frozen --extra dev (Python 3.13), and unset HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET. Main for comparison is 0080274d7.

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 32093c6cf's pence.py. CI at the head is green on every Python shard, YAML, lint and fix evidence. Aggregate impact finished with success while I wrote this. The findings below are about text. None of them gives a wrong result.

P1

None.

P2

None.

P3

P3-1. The body says the mutants ran "at this head", but they ran at 32093c6cf. (body, line 145)

  • handoff-664 says: "Not rerun: the M1–M8 mutants". The body's mutation paragraph says "We made each mutation below to the formula at this head".
  • The results still hold at df7893033:
    • df7893033 changes only pence.py's error state, which changes no value (see "Checked and sound").
    • I reran M3 at df7893033, and it is killed with the same failure (below).
  • "We made" also describes how the work was done rather than naming the formula or a test as the actor. Replace the sentence with:

    Each mutation below changes the formula at 32093c6cf, with everything else unchanged; df7893033 changes no value the excess or the taper reads, and M3 still fails at df7893033. The mutants ran against the new taper module and the two every-limit tests (…), 25 tests:

P3-2. A comment in the taper still says the excess is whole pence without the qualifier. (policyengine_uk/utils/carers_allowance_taper.py:66)

  • abeb50588 qualified the same claim in is_gainfully_employed_for_carers_allowance.py:36-38 and in the variable's documentation ("for a limit in whole pence"). The taper comment still says "The excess is whole pence".
  • Under the £204.105 reform from the last round, the excess is half a penny.
  • Replace it with:

    # For a limit in whole pence the excess is whole pence, which float32 holds only to within half a step (1p as 0.0099999998); stored_pence reads them back exactly, in float64, so the formula subtracts them from the rate in float64.

P3-3. handoff-664 is wrong that np.spacing of the subnormal doesn't warn.

  • The hand-off says: "Only np.nextafter from zero flags underflow; np.spacing(0) and np.spacing of the subnormal it returns don't", and later "np.spacing alone didn't warn".
  • Under warnings.simplefilter("error") and np.errstate(all="warn"):
    • np.spacing(np.float32(0)) is ok;
    • np.spacing(np.float32(1e-45)) raises RuntimeWarning('underflow encountered in spacing').
  • So keeping np.spacing inside the block is required, not a judgement call. I moved both np.spacing lines out of the block in a scratch copy (pence.py:88 and :171). Both new tests then fail with RuntimeWarning: underflow encountered in spacing, so the tests guard this. Only the hand-off text is wrong.
  • To stop a later edit narrowing the block, replace the comment at pence.py:83-84 and :168-169 with:

    # The step above zero is the smallest subnormal float32: numpy reports both nextafter, which returns it, and spacing of it as underflow. That step still bounds the rounding.

P3-4. The voice pass made a regulation act in two places. (body, lines 22 and 101)

  • I compared PR_BODY.author.md with the live body section by section. No replacement changes a fact (see "Checked and sound"). Two replacements, though, give a regulation the verb, which the writing rules don't allow.
    • Line 22, author: "PAYE and Class 1 are in law the amounts actually deducted (reg 10(4)(a))". Hub: "For PAYE and Class 1, reg 10(4)(a) deducts the amounts actually taken from the pay". Replace with:

      For PAYE and Class 1, the formula deducts the amounts actually taken from the pay (reg 10(4)(a)), which come in whole pence. carers_allowance_earnings rounds the model's figures for them up the same way, in the claimant's favour.

    • Line 101, hub: "Reg 5, applied to each deduction as the law requires, produces that fall." Replace with:

      The formula produces that fall by rounding each deduction up, as reg 5 requires.

  • The "What and why" bullets ("Reg 5 says", "Reg 8(1)(b)(iii) turns", "Reg 10(4) nets") have the same pattern. They are the same in both versions, so they predate this round.

Checked and sound

Previous round, one by one

  • P2-1, closed. carers_allowance_taper.py:66-68 carries the review's float64 text. Body rule item 4 gives the 2021 figure. git grep -n -i "no test can" finds nothing.
  • P2-2, closed.
    • _run returns carers_allowance_pre_couple_limit, and test_the_taper_follows_exact_pence asserts both bounds (…_properties.py:206-207).
    • M3 rerun at df7893033: I replaced np.maximum(0, ca.rate - taper.rate * excess) with (ca.rate - taper.rate * excess) and ran the taper module plus the two every-limit tests, -n 4. Result: 1 failed, 24 passed: killed.
    • The failure is test_the_taper_follows_exact_pence, at assert (pre_couple >= 0).all(), year, with AssertionError: 2020. Restored afterwards.
  • P2-3, closed. I entered carers_allowance_earnings at 52L, 52L + £0.51 and 52L + £0.52 for 2015–2031, for a carer in England aged 45 caring 40 hours, at the head and on main 0080274d7. The two runs used the same venv with PYTHONPATH pointing at each worktree. Every row of the effect table matches:
    • 2015–2019: both judge 1p over as over. Main's excess is 1.00021362p.
    • 2020–2024: main gives a 0p excess, not gainfully employed, and CA of £3,497.00, £3,515.20, £3,624.40, £3,991.00 and £4,258.80. The head gives an excess of 0.99999998p (float32 1p), gainfully employed, and CA nil.
    • 2025–2029: the limits are £196, £204, £212, £217 and £222. Both judge 1p over as gainfully employed with CA nil. Main's excess is 0.99945068p.
    • 2030–31 and 2031–32: both give £2,500.44 a penny over £228, and £5,114.90 a penny over £234. That is 52 × (98.3735 − 0.01), the rate less a penny.
    • 52L and 52L + £0.51 stay within the limit in every year, on both.
  • The changelog and body taper claim, reproduced. For 10,001 carers 0p to £100 a week over the 2031-32 limit (£234, rate 9,837.3469p):
    • the head's award lies within a float32 step (0.0488p) of 52 × max(0, R − E) for all 10,001;
    • main pays 1,700 of them more than that by over 26p, each about 52p (51.95p to 52.07p).
  • P3-1, closed.
  • P3-2, partly closed (P3-2 above).
  • P3-3, closed. The fragment has two sentences, and its claims match the code and the run above.
  • P3-4, closed.
    • The body has no mention of a review, a salvage, a probe script or a ~/ path (grep -i "review\|salvage\|~/\|probe\|r5/\|juaristi" on the live body finds only the word "review" inside other names).
    • In the delta, every added line is free of them.
    • The REVIEW_PAIRS → KNOWN_PAIRS rename is consistent, and the documentation sentence is replaced.
  • P3-5, closed. Rule item 1 has the supplied or reformed Class 1 sentence.

The underflow fix

  • No value changes. I loaded pence.py from 32093c6cf and from df7893033 side by side.

    • _step(0) is 1.40129846e-45 at both.
    • The summed tolerance at bound 0 with count 2 is 2.802596928649634e-43 at both.
    • Zero amounts give Pence(0, 0, 2) from summed and Pence(0, 0, 12) from months_summed at both.
    • 2,000 random summed calls (whole pence, ±£131,072, zeros mixed in) and 2,000 _step calls give 0 mismatches between the two files.
    • np.geterr() afterwards is numpy's default, so the block restores the error state.
  • Other callers of np.nextafter and np.spacing. git grep -n "nextafter\|spacing(" -- policyengine_uk ':!policyengine_uk/tests' finds only pence.py:35, :85, :88, :170, :171 and carers_allowance_earnings.py:205-206.

    • Under the strict state (simplefilter("error"), errstate(all="warn")), stored_pence(zeros) and np.spacing of float32 zeros in the form of :205-206 raise nothing.
    • Only np.nextafter from zero and np.spacing of a subnormal warn, and both now sit inside the blocks.
    • stored_pence would warn on a subnormal input such as £1e-45, which no whole-pence amount or sum of them can be.
  • Revert check. With pence.py from 32093c6cf in a scratch worktree:

    • test_zero_amounts_read_as_zero_pence_without_a_warning and test_zero_bounds_read_the_same_under_a_strict_error_state both fail: 2 failed, RuntimeWarning: underflow encountered in nextafter.
    • The property still fails with its two @examples deleted. Hypothesis finds parts=[0] and parts=[1] with zero months.
  • How often the property draws zero. I counted the property's own strategy over its 300 derandomized examples:

    • 2,415 of 4,236 drawn amounts are zero (57%);
    • 135 of 300 examples have all parts zero, which is a zero summed bound;
    • all 300 have at least one zero month, which is a zero _step bound;
    • 8 have all twelve months zero.

    So zero is drawn more than often enough, and 165 examples still test non-zero sums.

The body

  • Voice pass. I read each changed sentence of PR_BODY.author.md against the live body. Every number, case number, limit, award, date, SHA and test name is the same, and no change alters what the code is said to do. The figures I checked: £10,608.5195 and £10,608.52 are the same float32; a step below £131,072 is at most 0.78p; £131,072 / 52 is £2,520.6; and the aggregate-impact base 6030cbc0ba and 32f1639109 are the parents of c95ea031b0 (gh api …/commits/c95ea031b0).

  • Backticked names. I extracted every backticked name from the live body (gh api …/pulls/2543) and ran git grep -w -F or git ls-files on each at the head. All exist, including:

    • amount_pence, summed, months_summed, stored, _step, Pence;
    • test_zero_amounts_read_as_zero_pence_without_a_warning, test_zero_bounds_read_the_same_under_a_strict_error_state, test_bounds_and_differential, _over_limit;
    • fractions.Fraction, as from fractions import Fraction in the properties module.

    The only names with no match are the commit SHAs 6030cbc0ba and c95ea031b0 (CI merge commits, which the API resolves).

  • stored_sum_pence is gone from the body. The first lines are in order: Fixes #2541, Reproducer:, axiom:. The title is unchanged.

  • Invariant 8's counts match test_stored_pence.py: 200,000 sums per shape at :168, every amount below £32,768 at :212, and 11 tests.

Local runs at the head

Check Result
YAML carers_allowance_earnings_limit.yaml 37 passed
YAML carer_support_payment_earnings_limit.yaml 19 passed
Ruff 0.17.2 format --check . (2,167 files), check ., check --select F811,F821 policyengine_uk clean
git diff --check c4b633516..HEAD clean
pytest on the eight focused files, -n 4 213 passed: test_warning_filters.py 32, test_stored_pence.py 11, …earnings_limit_properties.py 59, …weekly_earnings_above_limit_properties.py 23, …pension_contribution_deduction_rate_properties.py 2, …earnings_taper.py 68, …earnings_limit_projection.py 8, test_carer_support_payment.py 10

CI at the head (gh api …/commits/df78930…/check-runs --paginate)

  • Passed:
    • Test / Python 1/7 to 7/7: 1,645, 1,665, 1,689, 1,638, 1,677, 1,636 and 1,681 passed, none failed;
    • Test / YAML and Test / Result;
    • Lint, Changelog fragments, Changes, Documentation build;
    • Fix evidence (both runs);
    • Install + smoke-import for py3.11 to py3.14;
    • Release build records the commit.
  • No job failed or was cancelled.
  • From the job logs:
    • test_the_divisions_the_filters_hid_raise_no_runtime_warning[baseline] and [reform] PASSED in shards 2/7 and 4/7. They failed at 32093c6cf.
    • Both new test_stored_pence.py zero tests PASSED in shard 6/7.
  • Aggregate impact (job 125534278896): completed, success. The job log holds only setup and git lines. The report goes to the step summary (cat report/comment.md >> "$GITHUB_STEP_SUMMARY"), which the REST API doesn't return, so I haven't read its figures.
  • No job is pending.

What I couldn't check

  • The figures of CI's Aggregate impact report at this head. The job passed, but its report is in the step summary, which I can't read, and there's no dataset in this session. The body's "At df78930: pending" line needs the report's figures, or a link to the run.
  • M1, M2 and M4–M8 at df7893033. I reran only M3, and I rely on the value-equivalence check above for the rest.
  • The whole code_health directory and .github/check_fix_pr.py locally. CI's Fix evidence passed, and the code_health tests ran inside the green shards.
  • I didn't count the hub's 66 replacements one by one. I compared the two bodies section by section.
  • The hand-offs' scratch logs on the hub's machine.

Generated by Claude Code

MaxGhenis and others added 2 commits November 2, 2026 17:14
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>
@MaxGhenis
MaxGhenis dismissed juaristi22’s stale review November 2, 2026 22:19

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.

@MaxGhenis
MaxGhenis marked this pull request as ready for review November 3, 2026 00:44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Carer's Allowance: a penny a week over the 2020-21 to 2024-25 earnings limits counts as within the limit

3 participants