diff --git a/changelog.d/613.fixed.md b/changelog.d/613.fixed.md new file mode 100644 index 00000000..6922c8a4 --- /dev/null +++ b/changelog.d/613.fixed.md @@ -0,0 +1 @@ +The UK Budget reform contract's Universal Credit allowance check now asserts on the award before deductions, and bounds how far deductions can lower the amount paid. diff --git a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py index 9f0c7509..bc0955c0 100644 --- a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py +++ b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py @@ -26,7 +26,15 @@ ``population_uc_standard_allowance_plus_10`` Every Universal Credit standard allowance 10% higher in 2027-28 raises each benefit unit's standard allowance by exactly 10%, lowers no award - and raises total Universal Credit. + before deductions and raises total Universal Credit. The award before + deductions is ``universal_credit_pre_benefit_cap`` less + ``benefit_cap_reduction``: the benefit cap reduces the award by the + excess of welfare benefits over the cap (Universal Credit Regulations + 2013, SI 2013/376, reg 81), so a capped award stays flat. Deductions are + then taken from the award and set as percentages of the standard + allowance (Claims and Payments Regulations 2013, SI 2013/380, Sch 6), so + the amount paid (``universal_credit``) can fall. The check requires each + payment to move by its award's change less its deductions' change. ``population_state_pension_plus_10`` The basic and new State Pension rates 10% higher in 2027-28 raise each person's basic and new State Pension by exactly 10% and raise total @@ -121,6 +129,115 @@ def population_check_routes(routes: Sequence[str]) -> tuple[tuple[str, str], ... ) +#: What the Universal Credit allowance check reads from each simulation. +UC_ALLOWANCE_VARIABLES = ( + "uc_standard_allowance", + "universal_credit", + "universal_credit_pre_benefit_cap", + "benefit_cap_reduction", + "uc_deductions", +) + +#: Universal Credit amounts are float32 annual pounds; sums of them carry +#: rounding of a few thousandths of a pound, so a penny is the smallest +#: difference the check treats as a change. +PENNY = 0.01 + +#: Deductions may not reduce the amount payable below a penny an assessment +#: period (SI 2013/380 Sch 6 para 3(1)(a)), twelve of them a year. +MINIMUM_PAYABLE_A_YEAR = 0.12 + + +def uc_allowance_verdict( + base: dict[str, np.ndarray], reformed: dict[str, np.ndarray] +) -> tuple[bool, dict]: + """Whether a 10% higher standard allowance moved Universal Credit lawfully. + + ``base`` and ``reformed`` hold ``UC_ALLOWANCE_VARIABLES`` per benefit unit. + + No award before deductions (``universal_credit_pre_benefit_cap`` less + ``benefit_cap_reduction``) may fall by more than a penny. The benefit cap + reduces the award by the excess of welfare benefits over the cap (SI + 2013/376 reg 81), counting Universal Credit before any deduction (reg + 80(2)), so a capped award stays flat. That holds in the model, which + applies no sanctions; in law a sanction, taken after the cap and set by + the standard allowance (regs 110 and 111), can lower a capped award. + + Each payment (``universal_credit``) must move by its award's change less + its deductions' change, to the penny. Deductions are taken from the award + (SI 2013/380 Sch 6), so with the award unchanged a capped claimant's + payment falls by exactly as much as their deductions rise. + + Whatever deductions are taken, at least a penny an assessment period + stays payable (SI 2013/380 Sch 6 para 3(1)(a)), so no payment falls below + the lesser of its award and 12p a year. Every value must be finite in both + runs: a missing value would otherwise pass every comparison. + + How much deductions rise is not checked here. The law sets some + deductions as percentages of the standard allowance and others as fixed + amounts (advances, SI 2013/383 reg 10), starts deductions with a new + award, and keeps a penny payable; the model scales every deduction type + with the allowance (policyengine-uk#2410), so no allowance-proportional + bound is an invariant of both. + """ + + non_finite = int( + np.sum( + ~np.all( + [ + np.isfinite(state[name]) + for state in (base, reformed) + for name in UC_ALLOWANCE_VARIABLES + ], + axis=0, + ) + ) + ) + allowance, base_allowance = ( + reformed["uc_standard_allowance"], + base["uc_standard_allowance"], + ) + scaled = bool(np.allclose(allowance, 1.1 * base_allowance, rtol=1e-6)) + award = ( + reformed["universal_credit_pre_benefit_cap"] - reformed["benefit_cap_reduction"] + ) + base_award = ( + base["universal_credit_pre_benefit_cap"] - base["benefit_cap_reduction"] + ) + paid, base_paid = reformed["universal_credit"], base["universal_credit"] + deduction_change = reformed["uc_deductions"] - base["uc_deductions"] + unaccounted = (paid - base_paid) - ((award - base_award) - deduction_change) + awards_lower = int(np.sum(award < base_award - PENNY)) + payments_unaccounted = int(np.sum(np.abs(unaccounted) > PENNY)) + payments_higher = int(np.sum(paid > base_paid + PENNY)) + payments_below_floor = int( + sum( + np.sum( + state_paid + < np.minimum(np.maximum(state_award, 0), MINIMUM_PAYABLE_A_YEAR) - PENNY + ) + for state_paid, state_award in ((base_paid, base_award), (paid, award)) + ) + ) + holds = ( + non_finite == 0 + and scaled + and awards_lower == 0 + and payments_unaccounted == 0 + and payments_below_floor == 0 + and payments_higher > 0 + ) + return holds, { + "standard_allowance_scaled_10_percent": scaled, + "awards_lower": awards_lower, + "payments_lower": int(np.sum(paid < base_paid - PENNY)), + "payments_higher": payments_higher, + "payments_not_award_less_deductions": payments_unaccounted, + "payments_below_the_payable_floor": payments_below_floor, + "units_with_non_finite_values": non_finite, + } + + def plus_ten_percent(paths: Sequence[str]) -> dict: """Each parameter 10% above current law for the response year.""" from policyengine_uk.system import system @@ -346,8 +463,7 @@ def values(simulation, variable): base = { variable: values(unreformed, variable) for variable in ( - "uc_standard_allowance", - "universal_credit", + *UC_ALLOWANCE_VARIABLES, "basic_state_pension", "new_state_pension", "state_pension", @@ -355,23 +471,17 @@ def values(simulation, variable): } for route in country: simulation = build(route, plus_ten_percent(UC_STANDARD_ALLOWANCES)) - allowance = values(simulation, "uc_standard_allowance") - award = values(simulation, "universal_credit") - scaled = bool( - np.allclose(allowance, 1.1 * base["uc_standard_allowance"], rtol=1e-6) - ) - lower = int(np.sum(award < base["universal_credit"] - 1e-3)) - higher = int(np.sum(award > base["universal_credit"] + 1e-3)) + uc_reformed = { + variable: values(simulation, variable) + for variable in UC_ALLOWANCE_VARIABLES + } + holds, detail = uc_allowance_verdict(base, uc_reformed) rows.append( _row( POPULATION_UC_STANDARD_ALLOWANCE_PLUS_10, route, - "pass" if scaled and lower == 0 and higher > 0 else "fail", - detail={ - "standard_allowance_scaled_10_percent": scaled, - "awards_lower": lower, - "awards_higher": higher, - }, + "pass" if holds else "fail", + detail=detail, ) ) for route in country: diff --git a/tests/test_uk_budget_reform_contract.py b/tests/test_uk_budget_reform_contract.py index f39728e0..85b592d4 100644 --- a/tests/test_uk_budget_reform_contract.py +++ b/tests/test_uk_budget_reform_contract.py @@ -505,6 +505,9 @@ def baseline(self): return Simulation(dataset=None) def calculate(self, variable, year): + # No cap reduction or deductions, so a payment equals its award. + if variable in ("benefit_cap_reduction", "uc_deductions"): + return [0.0] if ( variable == "income_tax" and year == 2027 @@ -512,7 +515,13 @@ def calculate(self, variable, year): ): return [2.0] if self.reform == {"response": population.UC_STANDARD_ALLOWANCES}: - if variable in ("uc_standard_allowance", "universal_credit"): + # The award before deductions rises with the payment, so the + # payment is the award less (unchanged) deductions. + if variable in ( + "uc_standard_allowance", + "universal_credit", + "universal_credit_pre_benefit_cap", + ): return [1.1] if self.reform == {"response": population.STATE_PENSION_RATES}: if variable in ( @@ -790,3 +799,220 @@ def test_household_result_totals_read_each_variable_from_its_own_entity(): assert _result_total(result, "income_tax") == 150.0 with pytest.raises(KeyError, match="benunit"): _result_total({"household": {"universal_credit": 1.0}}, "universal_credit") + + +# The Universal Credit allowance check's verdict, on synthetic benefit units. + + +def _uc_units(**columns): + import numpy as np + + defaults = { + "uc_standard_allowance": [400.0], + "universal_credit": [500.0], + "universal_credit_pre_benefit_cap": [500.0], + "benefit_cap_reduction": [0.0], + "uc_deductions": [0.0], + } + defaults.update(columns) + return {name: np.array(values, dtype=float) for name, values in defaults.items()} + + +def _verdict(base, reformed): + from policyengine.tax_benefit_models.uk.budget_reform_population import ( + uc_allowance_verdict, + ) + + return uc_allowance_verdict(base, reformed) + + +def _lawful_pair(): + """A capped unit whose deductions rise, and an uncapped unit that gains.""" + + base = _uc_units( + uc_standard_allowance=[400.0, 400.0], + universal_credit_pre_benefit_cap=[900.0, 500.0], + benefit_cap_reduction=[100.0, 0.0], + uc_deductions=[20.0, 0.0], + universal_credit=[780.0, 500.0], + ) + reformed = _uc_units( + uc_standard_allowance=[440.0, 440.0], + universal_credit_pre_benefit_cap=[940.0, 540.0], + benefit_cap_reduction=[140.0, 0.0], + uc_deductions=[22.0, 0.0], + universal_credit=[778.0, 540.0], + ) + return base, reformed + + +def test_a_capped_award_whose_deductions_rise_with_the_allowance_holds(): + """SI 2013/376 reg 81 holds a capped award flat; deductions come out of it + (SI 2013/380 Sch 6), so the amount paid falls by exactly their rise.""" + + holds, detail = _verdict(*_lawful_pair()) + assert holds, detail + assert detail["payments_lower"] == 1 + assert detail["awards_lower"] == 0 + + +@pytest.mark.parametrize("rise", [2.0, 60.0, 357.0]) +def test_how_much_deductions_rise_is_not_bounded(rise): + """Deductions can start with a new award or exceed the allowance rise.""" + + base, reformed = _lawful_pair() + reformed["uc_deductions"][0] = 20.0 + rise + reformed["universal_credit"][0] = 800.0 - 20.0 - rise + holds, detail = _verdict(base, reformed) + assert holds, detail + + +@pytest.mark.parametrize( + ("change", "failing_key", "passing_value"), + [ + ( + {"benefit_cap_reduction": 150.0, "universal_credit": 768.0}, + "awards_lower", + 0, + ), + ( + {"uc_deductions": 800.0, "universal_credit": 0.0}, + "payments_below_the_payable_floor", + 0, + ), + ({"uc_deductions": float("nan")}, "units_with_non_finite_values", 0), + ( + {"benefit_cap_reduction": float("nan")}, + "units_with_non_finite_values", + 0, + ), + ({"universal_credit": 777.989}, "payments_not_award_less_deductions", 0), + ({"universal_credit": 778.011}, "payments_not_award_less_deductions", 0), + ( + {"uc_deductions": 0.0, "universal_credit": 780.0}, + "payments_not_award_less_deductions", + 0, + ), + ( + {"uc_standard_allowance": 430.0}, + "standard_allowance_scaled_10_percent", + True, + ), + ], + ids=[ + "award-falls-with-its-payment", + "deductions-take-the-whole-award", + "deductions-not-finite", + "cap-reduction-not-finite", + "payment-below-award-less-deductions", + "payment-above-award-less-deductions", + "deductions-released-but-payment-unchanged", + "not-scaled", + ], +) +def test_the_uc_allowance_check_fails_on_each_unlawful_movement( + change, failing_key, passing_value +): + base, reformed = _lawful_pair() + assert _verdict(base, reformed)[0] + for name, value in change.items(): + reformed[name][0] = value + holds, detail = _verdict(base, reformed) + assert not holds + assert detail[failing_key] != passing_value, detail + + +@pytest.mark.parametrize( + "change", + [ + {"benefit_cap_reduction": 140.009}, + {"universal_credit": 777.991}, + {"universal_credit": 778.009}, + ], + ids=["award-within-a-penny", "payment-a-little-low", "payment-a-little-high"], +) +def test_float32_rounding_within_a_penny_holds(change): + base, reformed = _lawful_pair() + for name, value in change.items(): + reformed[name][0] = value + holds, detail = _verdict(base, reformed) + assert holds, detail + + +def test_the_uc_allowance_check_fails_when_no_payment_rises(): + base = _uc_units() + holds, detail = _verdict(base, _uc_units(uc_standard_allowance=[440.0])) + assert not holds and detail["payments_higher"] == 0 + + +def test_errors_that_cancel_across_units_still_fail(): + """The accounting identity holds per unit, not in aggregate.""" + + base, reformed = _lawful_pair() + reformed["universal_credit"][0] -= 5.0 + reformed["universal_credit"][1] += 5.0 + holds, detail = _verdict(base, reformed) + assert not holds + assert detail["payments_not_award_less_deductions"] == 2 + + +@pytest.mark.parametrize(("gain", "holds"), [(0.009, False), (0.011, True)]) +def test_a_gain_must_exceed_a_penny(gain, holds): + """Only a payment more than a penny higher counts as responsive.""" + + base = _uc_units( + universal_credit_pre_benefit_cap=[900.0], + benefit_cap_reduction=[100.0], + universal_credit=[800.0], + ) + reformed = _uc_units( + uc_standard_allowance=[440.0], + universal_credit_pre_benefit_cap=[940.0], + benefit_cap_reduction=[140.0 - gain], + universal_credit=[800.0 + gain], + ) + assert _verdict(base, reformed)[0] is holds + + +@pytest.mark.parametrize(("relative", "holds"), [(5e-7, True), (2e-6, False)]) +def test_the_allowance_must_scale_by_ten_percent(relative, holds): + base, reformed = _lawful_pair() + reformed["uc_standard_allowance"] = reformed["uc_standard_allowance"] * ( + 1 + relative + ) + assert _verdict(base, reformed)[0] is holds + + +def test_deductions_may_leave_exactly_the_payable_floor(): + """A penny an assessment period, 12p a year, is the lowest lawful payment.""" + + base, reformed = _lawful_pair() + reformed["uc_deductions"][0] = 800.0 - 0.12 + reformed["universal_credit"][0] = 0.12 + holds, detail = _verdict(base, reformed) + assert holds, detail + + +@pytest.mark.parametrize("route", ["country.reform_dict", "wrapper.policy"]) +def test_extraction_errors_and_unsupported_rows_carry_distinct_ids(monkeypatch, route): + """#612 review: ids also label error, pending and unsupported rows.""" + + from dataclasses import replace + + checks = ( + Check("variable", "income_tax", 2027, 0.0, "missing value"), + Check("variable", "income_tax", 2027, 0.0, "missing pending", pending="d1344"), + Check("baseline_equals", "income_tax", 2027, "income_tax", "baseline"), + ) + fake(monkeypatch, FakeRoute(reformed={}, unreformed={})) + rows = [ + row + for row in evaluate_case(replace(CASE, checks=checks), route) + if "assertion_id" in row + ] + assert len(rows) == len(checks) + assert len({row["assertion_id"] for row in rows}) == len(checks) + statuses = [row["status"] for row in rows] + assert statuses[:2] == ["error", "pending"] + if route == "wrapper.policy": + assert statuses[2] == "unsupported"