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/slow-python-tests-2026-11.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The NICs salary sacrifice cap, childcare hours and childcare income limit property tests build fewer simulations, with the same strategies, example counts and assertions.
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,19 @@ def new_simulation(**kwargs):
return Simulation(**kwargs)


def simulate(families, year, eligible, cap_bump=0, hours_bump=0):
"""One Simulation: each family appears once per value in ``eligible``."""
def simulate(families, year, eligible=(), variants=None):
"""One Simulation: each family appears once per value in ``eligible``.

``variants`` instead lists one ``(eligible, cap_bump, hours_bump)`` for
each copy, which adds ``cap_bump`` to the family's maximum hours used and
``hours_bump`` to each child's hours used. Every copy's families have
their own benefit units and households, and every copy supplies the same
inputs, so no copy reads another's.
"""
if variants is None:
variants = [(is_eligible, 0, 0) for is_eligible in eligible]
situation = {"people": {}, "benunits": {}, "households": {}}
for copy, is_eligible in enumerate(eligible):
for copy, (is_eligible, cap_bump, hours_bump) in enumerate(variants):
for i, f in enumerate(families):
members = []
for j, c in enumerate(f["children"]):
Expand Down Expand Up @@ -667,13 +676,43 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families):

@PROPERTY_SETTINGS
@given(years, st.lists(family, min_size=1, max_size=5), st.sampled_from([1, 5, 15]))
@example(
2025,
[
# A 3-year-old in a family whose maximum hours used, 40, exceed the
# working parent total: raising them must not lower that total.
{
"children": [{"age": 3, "hours_used": 45, "looked_after": False}],
"cap": 40,
"claims_universal": True,
"claims_targeted": False,
"targeted": False,
"england": True,
},
# A 3-year-old whose own hours used bind, below the family's maximum.
{
"children": [{"age": 3, "hours_used": 20, "looked_after": False}],
"cap": 30,
"claims_universal": True,
"claims_targeted": False,
"targeted": False,
"england": True,
},
],
5,
)
def test_more_hours_used_never_lowers_childcare_support(year, families, bump):
lower = split(simulate(families, year, eligible=[True]), families, 1)[0]
for cap_bump, hours_bump in ((bump, 0), (0, bump)):
raised = simulate(
families, year, eligible=[True], cap_bump=cap_bump, hours_bump=hours_bump
)
higher = split(raised, families, 1)[0]
raises = ((bump, 0), (0, bump))
lower, *raised = split(
simulate(
families,
year,
variants=[(True, 0, 0)] + [(True, *raise_) for raise_ in raises],
),
families,
1 + len(raises),
)
for (cap_bump, hours_bump), higher in zip(raises, raised):
for f, lo, hi in zip(families, lower, higher):
for c, a, b in zip(f["children"], lo, hi):
assert funded(b) >= funded(a) - 0.02, (year, f, c, bump, a, b)
Expand Down Expand Up @@ -730,8 +769,10 @@ def test_raising_the_income_limit_never_lowers_support_or_income(h):
},
"households": {"hh": {"members": members, "country": {year: "ENGLAND"}}},
}
base = new_simulation(situation=situation)
reform = new_simulation(situation=situation, reform={INCOME_LIMIT: math.inf})
# A reformed Simulation builds its baseline as a current-law Simulation of
# the same situation.
base = reform.baseline

def total(sim, variable):
return float(sim.calculate(variable, year).sum())
Expand All @@ -745,3 +786,38 @@ def total(sim, variable):
)
assert d_income >= -0.05, h
assert abs(d_income - (after - before)) <= 0.05, h


@pytest.mark.parametrize("high, funded", [(100_000, True), (100_001, False)])
def test_income_limit_admits_exactly_the_couples_within_it(high, funded):
"""A working couple's 3-year-old gets working parent hours exactly when
both parents' adjusted net income is at most £100,000 (SI 2022/1134 regs
14(3)(c)(i) and 15(3)(b)(i))."""
year = 2026
people = {
"parent1": {
"age": {year: 35},
"employment_income": {year: high},
"is_parent": {year: True},
},
"parent2": {
"age": {year: 35},
"employment_income": {year: 20_000},
"is_parent": {year: True},
},
"child0": {"age": {year: 3}},
}
members = list(people)
situation = {
"people": people,
"benunits": {
"bu": {
"members": members,
"maximum_extended_childcare_hours_usage": {year: 30},
}
},
"households": {"hh": {"members": members, "country": {year: "ENGLAND"}}},
}
sim = new_simulation(situation=situation)
extended = float(sim.calculate("extended_childcare_entitlement", year).sum())
assert (extended > 0) == funded, (high, extended)
171 changes: 107 additions & 64 deletions policyengine_uk/tests/test_statutory_pension_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,18 @@ def _assert_ani_input_path(sim, year, supplied_ani):
assert [str(period) for period in periods] == ([str(year)] if supplied_ani else [])


def _charge_with_fixed_ordinary_tax_inputs(results, year):
def _charges_with_fixed_ordinary_tax_inputs(result_sets, year):
"""Test the AA inputs to the charge separately from ordinary income tax.

A fixed additional-rate base, no RAS and no allowances keep the charge
independent of the cap's separate effects on ANI/personal allowances.
The taper incomes and pension input above are always calculated from
the original pay/contribution inputs, without these overrides.

One simulation holds the people of every result set. Each person has
their own benefit unit and household and supplies the same inputs as
every other, so no person's charge reads another's. Returns each result
set's charges.
"""
people = [
{
Expand All @@ -281,20 +286,67 @@ def _charge_with_fixed_ordinary_tax_inputs(results, year):
"pension_contributions_for_annual_allowance": float(pension_input),
"pension_annual_allowance": float(allowance),
}
for results in result_sets
for pension_input, allowance in zip(
results["pension_contributions_for_annual_allowance"],
results["pension_annual_allowance"],
)
]
sim = _population_simulation(people, year)
charge = np.asarray(sim.calculate("personal_pension_contributions_tax", year))
excess = np.maximum(
0,
results["pension_contributions_for_annual_allowance"]
- results["pension_annual_allowance"],
)
np.testing.assert_allclose(charge, excess * 0.45, rtol=0, atol=0.05)
return charge
sizes = [len(results["pension_annual_allowance"]) for results in result_sets]
charges = np.split(charge, np.cumsum(sizes)[:-1])
for results, set_charge in zip(result_sets, charges):
excess = np.maximum(
0,
results["pension_contributions_for_annual_allowance"]
- results["pension_annual_allowance"],
)
np.testing.assert_allclose(set_charge, excess * 0.45, rtol=0, atol=0.05)
return charges


def _aa_results_under_both_caps(people, year, scheme, supplied_ani):
"""The AA variables with the NICs cap at 2,000 and with no cap."""
results = []
for cap in [2_000, float("inf")]:
sim = _population_simulation(people, year, cap)
_assert_pension_input_path(sim, year, scheme)
_assert_ani_input_path(sim, year, supplied_ani)
results.append(
{
variable: np.asarray(sim.calculate(variable, year))
for variable in AA_VARIABLES
}
)
return results


def _assert_caps_agree(groups, year):
"""Each group's AA variables and charge agree under both cap settings.

``groups`` maps a label to the results ``_aa_results_under_both_caps``
returns. One charge simulation serves every group and both caps.
"""
for label, results in groups.items():
for variable in AA_VARIABLES:
np.testing.assert_allclose(
results[0][variable],
results[1][variable],
rtol=0,
atol=0.05,
err_msg=f"{label}: {variable}",
)
result_sets = [results[cap] for results in groups.values() for cap in (0, 1)]
charges = _charges_with_fixed_ordinary_tax_inputs(result_sets, year)
for index, label in enumerate(groups):
np.testing.assert_allclose(
charges[2 * index],
charges[2 * index + 1],
rtol=0,
atol=0.05,
err_msg=f"{label}: personal_pension_contributions_tax",
)


@pytest.mark.parametrize("year", [2029, 2030])
Expand All @@ -315,6 +367,8 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(

The charge comparison fixes ordinary tax inputs. It makes no unrestricted
claim about the charge, ANI, personal allowances or ordinary income tax.
``test_nics_cap_preserves_aa_inputs_and_charge_with_supplied_ani`` tests
the fixed populations with a supplied ANI.
"""
# Keep every default DC record, including the fixed witnesses, separate
# from supplied DB inputs, which otherwise zero the DC input array.
Expand All @@ -333,6 +387,25 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(
"personal_pension_contributions": 3_600,
},
] + dc_people
groups = {
"DC": _aa_results_under_both_caps(dc_people, year, "DC", False),
"DB": _aa_results_under_both_caps(db_people, year, "DB", False),
}
for results in groups["DC"]:
np.testing.assert_array_equal(
results["pension_contributions_for_annual_allowance"][:2],
[130_000, 304_600],
)
_assert_caps_agree(groups, year)


@pytest.mark.parametrize("year", [2029, 2030])
def test_nics_cap_preserves_aa_inputs_and_charge_with_supplied_ani(year):
"""Supplied ANI fixes threshold income whatever the NICs cap.

These populations depend on no drawn value, so this test runs them once
for each year.
"""
# ANI is a partial-input override, so each person in these separate
# simulations must supply it. Loss relief is already reflected in ANI;
# neither actual nor synthetic pay/loss relief can reconstruct it again.
Expand All @@ -358,64 +431,34 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(
{**person, "pension_contributions_for_annual_allowance": 350_000}
for person in ani_people
]
for scheme, people, supplied_ani in [
("DC", dc_people, False),
("DB", db_people, False),
("DC", ani_people, True),
("DB", ani_db_people, True),
]:
results = []
for cap in [2_000, float("inf")]:
sim = _population_simulation(people, year, cap)
_assert_pension_input_path(sim, year, scheme)
_assert_ani_input_path(sim, year, supplied_ani)
results.append(
{
variable: np.asarray(sim.calculate(variable, year))
for variable in AA_VARIABLES
}
groups = {
"DC with ANI": _aa_results_under_both_caps(ani_people, year, "DC", True),
"DB with ANI": _aa_results_under_both_caps(ani_db_people, year, "DB", True),
}
# Gross Gift Aid is 1,000. At age 45 actual earnings allow 2,000 net-pay
# relief or 3,600 RAS relief; age 80 allows neither. The loss never
# changes an already supplied ANI.
expected_adjusted = {
"DC with ANI": [451_000, 454_000, 455_600] * 2
+ [451_000, 448_000, 448_000] * 2,
"DB with ANI": [500_000, 499_000, 500_600] * 2
+ [500_000, 497_000, 497_000] * 2,
}
for label, both_caps in groups.items():
for results in both_caps:
np.testing.assert_array_equal(
results["pension_annual_allowance_threshold_income"],
[351_000, 352_000, 352_000] * 4,
)
if supplied_ani:
# Gross Gift Aid is 1,000. At age 45 actual earnings allow
# 2,000 net-pay relief or 3,600 RAS relief; age 80 allows
# neither. The loss never changes an already supplied ANI.
expected_adjusted = (
[451_000, 454_000, 455_600] * 2 + [451_000, 448_000, 448_000] * 2
if scheme == "DC"
else [500_000, 499_000, 500_600] * 2
+ [500_000, 497_000, 497_000] * 2
)
np.testing.assert_array_equal(
results[-1]["pension_annual_allowance_threshold_income"],
[351_000, 352_000, 352_000] * 4,
)
np.testing.assert_array_equal(
results[-1]["pension_annual_allowance_adjusted_income"],
expected_adjusted,
)
np.testing.assert_array_equal(
results[-1]["pension_annual_allowance"],
np.full(len(people), 10_000),
)
elif scheme == "DC":
np.testing.assert_array_equal(
results[-1]["pension_contributions_for_annual_allowance"][:2],
[130_000, 304_600],
)
for variable in AA_VARIABLES:
np.testing.assert_allclose(
results[0][variable],
results[1][variable],
rtol=0,
atol=0.05,
err_msg=f"{scheme}: {variable}",
np.testing.assert_array_equal(
results["pension_annual_allowance_adjusted_income"],
expected_adjusted[label],
)
np.testing.assert_allclose(
_charge_with_fixed_ordinary_tax_inputs(results[0], year),
_charge_with_fixed_ordinary_tax_inputs(results[1], year),
rtol=0,
atol=0.05,
)
np.testing.assert_array_equal(
results["pension_annual_allowance"],
np.full(len(ani_people), 10_000),
)
_assert_caps_agree(groups, year)


@pytest.mark.parametrize("year", [2029, 2030])
Expand Down
Loading