diff --git a/changelog.d/slow-python-tests-2026-11.changed.md b/changelog.d/slow-python-tests-2026-11.changed.md new file mode 100644 index 0000000000..9236751a2b --- /dev/null +++ b/changelog.d/slow-python-tests-2026-11.changed.md @@ -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. diff --git a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py index 3add29cbbb..85193a123f 100644 --- a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py +++ b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py @@ -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"]): @@ -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) @@ -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()) @@ -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) diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py index f1742fed8d..d34b0e7881 100644 --- a/policyengine_uk/tests/test_statutory_pension_edges.py +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -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 = [ { @@ -281,6 +286,7 @@ 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"], @@ -288,13 +294,59 @@ def _charge_with_fixed_ordinary_tax_inputs(results, year): ] 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]) @@ -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. @@ -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. @@ -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])