Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2532.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -64,39 +69,71 @@ 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)


@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))
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"]
Expand All @@ -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(
Expand All @@ -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
)
Expand Down
Loading