From 370aade34e56ad36835d43bc2648604d6b61eceb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 11:13:40 -0400 Subject: [PATCH 1/3] Count CA earnings-limit pence in the model's float32 in the property test test_bounds_and_differential recomputed the reg. 5 penny rounding on earnings converted to float64, while is_gainfully_employed_for_carers_allowance floors the weekly pence on the stored float32 value. At pay of 10,608.52 in 2026 (stored as 10,608.51953125) the formula counts 20,401 weekly pence and the float64 recomputation 20,400, so a draw there failed at random. Keep each variable's stored dtype in _person() and count the pence on it through _over_limit(); tolerance checks convert with float(). Add explicit examples at 10,608.00 (exactly at the limit) and 10,608.52 (a penny over), and derandomize the three property tests. Fixes #2532 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2532.fixed.md | 1 + ...ers_allowance_earnings_limit_properties.py | 44 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2532.fixed.md diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md new file mode 100644 index 0000000000..3b23ecec4b --- /dev/null +++ b/changelog.d/2532.fixed.md @@ -0,0 +1 @@ +- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), so a draw just over a penny boundary no longer fails at random. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index 497d9ad7f9..a75d71455d 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -14,8 +14,11 @@ 1. Bounds: 0 <= carers_allowance_earnings <= pay + max(0, profit). 2. Differential: the test is passed exactly when - carers_allowance_earnings / 52 <= 204, and the allowance is the full rate - then and nil otherwise. + carers_allowance_earnings / 52, with a fraction of a penny disregarded + (Computation of Earnings Regulations 1996 reg. 5), is at most 204, and the + allowance is the full rate then and nil otherwise. The pence are counted + in the precision the model stores earnings in (float32), as the formula + counts them. 3. Monotonic: raising pay never lowers net earnings, so a person over the limit stays over it. 4. Unearned income is not earnings: savings interest and pension income @@ -24,7 +27,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -54,8 +57,10 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): }, } sim = Simulation(situation=situation) + # Keep each variable's stored dtype: the reg. 5 check must count pence in + # the precision the formula does. Tolerance checks convert with float(). return { - name: float(np.asarray(sim.calculate(name, 2026))[0]) + name: np.asarray(sim.calculate(name, 2026))[0] for name in ( "carers_allowance_earnings", "is_gainfully_employed_for_carers_allowance", @@ -64,6 +69,15 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): } +def _over_limit(earnings): + # Regulation 5: a fraction of a penny is disregarded. ``earnings`` is the + # stored value, so this counts the pence as + # is_gainfully_employed_for_carers_allowance does, in float32: promoting + # to float64 first floors 10,608.52 a year, stored as 10,608.51953125, to + # 204.00 a week instead of 204.01. + return np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + + money = st.floats(min_value=0, max_value=60_000, allow_nan=False) profit_money = st.floats(min_value=-20_000, max_value=60_000, allow_nan=False) @@ -71,24 +85,30 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): @settings( max_examples=12, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, profit=profit_money, pension_share=st.floats(0, 0.3)) +# Exactly at the 2026-27 limit (10,608 / 52 = 204.00): not gainfully employed. +@example(pay=10_608.0, profit=0.0, pension_share=0.0) +# One penny a week over it (10,608.52 / 52 = 204.01), on the float32 boundary +# described in _over_limit: gainfully employed. +@example(pay=10_608.52, profit=0.0, pension_share=0.0) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share result = _person(pay, profit, pension) earnings = result["carers_allowance_earnings"] - assert -0.01 <= earnings <= pay + max(0, profit) + 0.01 - # Regulation 5: a fraction of a penny is disregarded. - over = np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + assert -0.01 <= float(earnings) <= pay + max(0, profit) + 0.01 + over = _over_limit(earnings) assert bool(result["is_gainfully_employed_for_carers_allowance"]) == over expected = 0 if over else RATE * WEEKS - assert abs(result["carers_allowance"] - expected) < 0.01 + assert abs(float(result["carers_allowance"]) - expected) < 0.01 @settings( max_examples=8, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, rise=st.floats(min_value=1, max_value=20_000)) @@ -96,7 +116,8 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): lower = _person(pay, 0, 0) higher = _person(pay + rise, 0, 0) assert ( - higher["carers_allowance_earnings"] >= lower["carers_allowance_earnings"] - 0.01 + float(higher["carers_allowance_earnings"]) + >= float(lower["carers_allowance_earnings"]) - 0.01 ) if lower["is_gainfully_employed_for_carers_allowance"]: assert higher["is_gainfully_employed_for_carers_allowance"] @@ -105,6 +126,7 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): @settings( max_examples=6, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given( @@ -117,8 +139,8 @@ def test_unearned_income_is_not_earnings(pay, savings, private_pension): with_unearned = _person(pay, 0, 0, savings=savings, private_pension=private_pension) assert ( abs( - alone["carers_allowance_earnings"] - - with_unearned["carers_allowance_earnings"] + float(alone["carers_allowance_earnings"]) + - float(with_unearned["carers_allowance_earnings"]) ) < 0.01 ) From 3b466a0b7f89d471eff7745175fe6161f67730d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 16:40:57 -0400 Subject: [PATCH 2/3] Pin the 2026 penny-over outcome and cover a trading loss From the GPT-6.1 Sol review of #2534: - Add test_a_penny_a_week_over_the_2026_limit_ends_entitlement, which asserts the outcome for 10,608.00 (not gainfully employed, full rate) and 10,608.52 (gainfully employed, nil) directly, not only agreement with _over_limit. - Add an @example with a trading loss and earnings below the limit (pay 8,000, profit -5,000, pension share 0.1); the derandomized draws have neither. - Correct the changelog: the value the float64 recompute got wrong is stored just below the exact boundary, and float32 arithmetic rounds it across. The float32 formula's own boundary behaviour is #2541. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2532.fixed.md | 2 +- ...carers_allowance_earnings_limit_properties.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md index 3b23ecec4b..ec250d10d2 100644 --- a/changelog.d/2532.fixed.md +++ b/changelog.d/2532.fixed.md @@ -1 +1 @@ -- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), so a draw just over a penny boundary no longer fails at random. +- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), as the formula does, so a draw whose float32 arithmetic rounds across a penny boundary no longer fails at random. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index a75d71455d..1613bc2003 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -94,6 +94,9 @@ def _over_limit(earnings): # One penny a week over it (10,608.52 / 52 = 204.01), on the float32 boundary # described in _over_limit: gainfully employed. @example(pay=10_608.52, profit=0.0, pension_share=0.0) +# A trading loss, with earnings below the limit: the derandomized draws have +# neither. +@example(pay=8_000.0, profit=-5_000.0, pension_share=0.1) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share result = _person(pay, profit, pension) @@ -105,6 +108,19 @@ def test_bounds_and_differential(pay, profit, pension_share): assert abs(float(result["carers_allowance"]) - expected) < 0.01 +@pytest.mark.parametrize("pay, gainful", [(10_608.00, False), (10_608.52, True)]) +def test_a_penny_a_week_over_the_2026_limit_ends_entitlement(pay, gainful): + """£10,608 a year is £204.00 a week, at the limit; £10,608.52 is £204.01, a + penny over it. This pins the outcome for the amount entered, not only + agreement with _over_limit. The model stores £10,608.52 as 10,608.51953125 + (£204.00 a week in exact arithmetic) and reaches £204.01 through float32 + rounding; see #2541.""" + result = _person(pay, 0, 0) + assert bool(result["is_gainfully_employed_for_carers_allowance"]) == gainful + expected = 0 if gainful else RATE * WEEKS + assert abs(float(result["carers_allowance"]) - expected) < 0.01 + + @settings( max_examples=8, deadline=None, From ddede0b61eb95e679bda09029d7ddf88b991304d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 05:00:51 -0400 Subject: [PATCH 3/3] Say which coverage the trading-loss example adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review of #2534 found that the derandomized draws do include below-limit earners, at £0 to £0.12 a year. What they lack is a real trading loss and earnings between a few pence and the limit. Comment only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_carers_allowance_earnings_limit_properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index 1613bc2003..146a82091e 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -95,7 +95,7 @@ def _over_limit(earnings): # described in _over_limit: gainfully employed. @example(pay=10_608.52, profit=0.0, pension_share=0.0) # A trading loss, with earnings below the limit: the derandomized draws have -# neither. +# no real loss and no earnings between a few pence and the limit. @example(pay=8_000.0, profit=-5_000.0, pension_share=0.1) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share