diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md new file mode 100644 index 0000000000..ec250d10d2 --- /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), 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 497d9ad7f9..146a82091e 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,46 @@ 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) +# A trading loss, with earnings below the limit: the derandomized draws have +# 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 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 + + +@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, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, rise=st.floats(min_value=1, max_value=20_000)) @@ -96,7 +132,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 +142,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 +155,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 )