From ff4ea7870ae18185cc9022173450ef09fb3f1ebb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 08:43:34 -0400 Subject: [PATCH 1/7] Check the UC allowance response on the award before deductions W1's population_uc_standard_allowance_plus_10 required that no universal_credit amount fall when every standard allowance rises 10%. universal_credit is the amount paid, net of deductions. Under the benefit cap (Universal Credit Regulations 2013, SI 2013/376, reg 81) a capped award stays flat, and deductions are taken from the award as percentages of the standard allowance (Claims and Payments Regulations 2013, SI 2013/380, Sch 6), so a capped claimant with deductions is paid less. policyengine-uk 2.130.1 applies deductions to simulations built from data (#1905), so one benefit unit in the 1,000-household subsample shows exactly that; 2.126.12 applied none below 1e6 benefit-unit weight, so the subsample never had deductions. The check now asserts what the law makes invariant: - no award before deductions (universal_credit_pre_benefit_cap less benefit_cap_reduction) falls; - no payment falls by more than its deductions rise; - no deductions rise by more than the standard allowance; plus, as before, exact 10% scaling and at least one higher payment. The verdict is a pure function, uc_allowance_verdict, with a test for the lawful capped case and one for each unlawful movement. Co-Authored-By: Claude Opus 5.5 --- .../uk/budget_reform_population.py | 94 +++++++++++++--- tests/test_uk_budget_reform_contract.py | 101 ++++++++++++++++++ 2 files changed, 179 insertions(+), 16 deletions(-) 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 9a0a7974..9fe5bb8c 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,16 @@ ``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 bounds that + fall: no payment falls by more than its deductions rise, and no + deductions rise by more than the standard allowance. ``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 @@ -93,6 +102,66 @@ 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", +) + + +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. The benefit cap reduces the award by the excess of welfare benefits + over the cap (SI 2013/376 reg 81), so a capped award stays flat and no + award before deductions may fall. Deductions are taken from the award as + percentages of the standard allowance (SI 2013/380 Sch 6), so the amount + paid may fall, but by no more than the deductions rise, and deductions + may rise by no more than the standard allowance. + """ + + 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_rise = reformed["uc_deductions"] - base["uc_deductions"] + awards_lower = int(np.sum(award < base_award - 1e-3)) + payments_higher = int(np.sum(paid > base_paid + 1e-3)) + beyond_deductions = int( + np.sum((base_paid - paid) - np.maximum(deduction_rise, 0) > 1e-3) + ) + beyond_allowance = int(np.sum(deduction_rise - (allowance - base_allowance) > 1e-3)) + holds = ( + scaled + and awards_lower == 0 + and beyond_deductions == 0 + and beyond_allowance == 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 - 1e-3)), + "payments_higher": payments_higher, + "payment_falls_beyond_deduction_rises": beyond_deductions, + "deduction_rises_beyond_allowance_rises": beyond_allowance, + } + + def plus_ten_percent(paths: Sequence[str]) -> dict: """Each parameter 10% above current law for the response year.""" from policyengine_uk.system import system @@ -314,8 +383,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", @@ -323,23 +391,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 a4113e2b..0706369c 100644 --- a/tests/test_uk_budget_reform_contract.py +++ b/tests/test_uk_budget_reform_contract.py @@ -620,3 +620,104 @@ 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 with deductions 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; SI 2013/380 Sch 6 + deductions scale with the allowance, so the amount paid can fall.""" + + base, reformed = _lawful_pair() + holds, detail = _verdict(base, reformed) + assert holds, detail + assert detail["payments_lower"] == 1 + assert detail["awards_lower"] == 0 + + +@pytest.mark.parametrize( + ("change", "failing_key", "passing_value"), + [ + ({"benefit_cap_reduction": 150.0}, "awards_lower", 0), + ( + {"universal_credit": 770.0}, + "payment_falls_beyond_deduction_rises", + 0, + ), + ( + {"uc_deductions": 70.0, "universal_credit": 730.0}, + "deduction_rises_beyond_allowance_rises", + 0, + ), + ( + {"uc_standard_allowance": 430.0}, + "standard_allowance_scaled_10_percent", + True, + ), + ], + ids=[ + "award-falls", + "payment-falls-beyond-deductions", + "deductions-beyond-allowance", + "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 + + +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 From bdbd5178a92c0b4656d0ae7f872eb8db83ae845d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 08:46:26 -0400 Subject: [PATCH 2/7] Bound UC deduction rises by each unit's own rate The UK hub pointed out that "deductions rise by no more than the standard allowance" holds only while uc_deduction_rate <= 1: policyengine-uk 2.130.1 adds the latent excess above the deductions cap (deductions the cap does not limit) with no bound of its own. The check now requires that no unit's deduction rate changes under the reform (the rate does not depend on the allowance) and that its deductions rise by no more than that rate times the allowance rise, which is tighter for ordinary rates and still correct above 1. Tests cover a rate above 1 and a changed rate. Co-Authored-By: Claude Opus 5.5 --- .../uk/budget_reform_population.py | 29 ++++++++++++++----- tests/test_uk_budget_reform_contract.py | 22 ++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) 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 9fe5bb8c..3a2d65af 100644 --- a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py +++ b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py @@ -34,8 +34,9 @@ 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 bounds that - fall: no payment falls by more than its deductions rise, and no - deductions rise by more than the standard allowance. + fall: no payment falls by more than its deductions rise, no unit's + deduction rate changes, and no deductions rise by more than that rate + times the standard allowance rise. ``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 @@ -109,6 +110,7 @@ def population_check_routes(routes: Sequence[str]) -> tuple[tuple[str, str], ... "universal_credit_pre_benefit_cap", "benefit_cap_reduction", "uc_deductions", + "uc_deduction_rate", ) @@ -122,8 +124,13 @@ def uc_allowance_verdict( over the cap (SI 2013/376 reg 81), so a capped award stays flat and no award before deductions may fall. Deductions are taken from the award as percentages of the standard allowance (SI 2013/380 Sch 6), so the amount - paid may fall, but by no more than the deductions rise, and deductions - may rise by no more than the standard allowance. + paid may fall, but by no more than the deductions rise. In the model a + unit's deductions are its ``uc_deduction_rate`` times the standard + allowance, capped by the award; the rate does not depend on the + allowance, so it must not change, and deductions may rise by no more + than that rate times the allowance rise. (The rate can exceed 1: the + excess above the deductions cap, for deductions the cap does not limit, + has no bound of its own.) """ allowance, base_allowance = ( @@ -139,17 +146,24 @@ def uc_allowance_verdict( ) paid, base_paid = reformed["universal_credit"], base["universal_credit"] deduction_rise = reformed["uc_deductions"] - base["uc_deductions"] + rate = reformed["uc_deduction_rate"] + rates_changed = int( + np.sum(~np.isclose(rate, base["uc_deduction_rate"], rtol=1e-9, atol=1e-12)) + ) awards_lower = int(np.sum(award < base_award - 1e-3)) payments_higher = int(np.sum(paid > base_paid + 1e-3)) beyond_deductions = int( np.sum((base_paid - paid) - np.maximum(deduction_rise, 0) > 1e-3) ) - beyond_allowance = int(np.sum(deduction_rise - (allowance - base_allowance) > 1e-3)) + beyond_rate = int( + np.sum(deduction_rise - rate * (allowance - base_allowance) > 1e-3) + ) holds = ( scaled and awards_lower == 0 and beyond_deductions == 0 - and beyond_allowance == 0 + and rates_changed == 0 + and beyond_rate == 0 and payments_higher > 0 ) return holds, { @@ -158,7 +172,8 @@ def uc_allowance_verdict( "payments_lower": int(np.sum(paid < base_paid - 1e-3)), "payments_higher": payments_higher, "payment_falls_beyond_deduction_rises": beyond_deductions, - "deduction_rises_beyond_allowance_rises": beyond_allowance, + "deduction_rates_changed": rates_changed, + "deduction_rises_beyond_rate_times_allowance_rise": beyond_rate, } diff --git a/tests/test_uk_budget_reform_contract.py b/tests/test_uk_budget_reform_contract.py index 0706369c..591262a6 100644 --- a/tests/test_uk_budget_reform_contract.py +++ b/tests/test_uk_budget_reform_contract.py @@ -634,6 +634,7 @@ def _uc_units(**columns): "universal_credit_pre_benefit_cap": [500.0], "benefit_cap_reduction": [0.0], "uc_deductions": [0.0], + "uc_deduction_rate": [0.0], } defaults.update(columns) return {name: np.array(values, dtype=float) for name, values in defaults.items()} @@ -655,6 +656,7 @@ def _lawful_pair(): universal_credit_pre_benefit_cap=[900.0, 500.0], benefit_cap_reduction=[100.0, 0.0], uc_deductions=[20.0, 0.0], + uc_deduction_rate=[0.05, 0.0], universal_credit=[780.0, 500.0], ) reformed = _uc_units( @@ -662,6 +664,7 @@ def _lawful_pair(): universal_credit_pre_benefit_cap=[940.0, 540.0], benefit_cap_reduction=[140.0, 0.0], uc_deductions=[22.0, 0.0], + uc_deduction_rate=[0.05, 0.0], universal_credit=[778.0, 540.0], ) return base, reformed @@ -688,10 +691,11 @@ def test_a_capped_award_whose_deductions_rise_with_the_allowance_holds(): 0, ), ( - {"uc_deductions": 70.0, "universal_credit": 730.0}, - "deduction_rises_beyond_allowance_rises", + {"uc_deductions": 30.0, "universal_credit": 770.0}, + "deduction_rises_beyond_rate_times_allowance_rise", 0, ), + ({"uc_deduction_rate": 0.06}, "deduction_rates_changed", 0), ( {"uc_standard_allowance": 430.0}, "standard_allowance_scaled_10_percent", @@ -701,7 +705,8 @@ def test_a_capped_award_whose_deductions_rise_with_the_allowance_holds(): ids=[ "award-falls", "payment-falls-beyond-deductions", - "deductions-beyond-allowance", + "deductions-beyond-rate", + "rate-changed", "not-scaled", ], ) @@ -721,3 +726,14 @@ 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_a_deduction_rate_above_one_bounds_by_the_rate_not_the_allowance(): + """Deductions the cap does not limit can exceed the standard allowance.""" + + base, reformed = _lawful_pair() + base["uc_deduction_rate"][0] = reformed["uc_deduction_rate"][0] = 1.5 + base["uc_deductions"][0], reformed["uc_deductions"][0] = 600.0, 660.0 + base["universal_credit"][0], reformed["universal_credit"][0] = 200.0, 140.0 + holds, detail = _verdict(base, reformed) + assert holds, detail From bb2d039e555bc7a86c32e967ac9f88e3dac5d449 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 08:51:15 -0400 Subject: [PATCH 3/7] Add changelog fragment for #613 Co-Authored-By: Claude Opus 5.5 --- changelog.d/613.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/613.fixed.md 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. From 34190f55c44f54de0d53d2f24165d9a989dec11b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 08:54:33 -0400 Subject: [PATCH 4/7] Cite reg 80(2) and the model's gaps in the UC allowance check The UK hub's independent legal check (AGREE_WITH_CHANGES) confirms the invariants as model invariants: Universal Credit counts towards the cap before any deduction (SI 2013/376 reg 80(2)); in law a sanctioned capped award can fall (regs 110, 111), which the model, applying no sanctions, cannot show; advances are recovered as fixed amounts (SI 2013/383 reg 10), so the model's deduction rise is an upper bound (policyengine-uk#2410). Co-Authored-By: Claude Opus 5.5 --- .../uk/budget_reform_population.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 3a2d65af..d99c2b8c 100644 --- a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py +++ b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py @@ -119,18 +119,22 @@ def uc_allowance_verdict( ) -> tuple[bool, dict]: """Whether a 10% higher standard allowance moved Universal Credit lawfully. - ``base`` and ``reformed`` hold ``UC_ALLOWANCE_VARIABLES`` per benefit - unit. The benefit cap reduces the award by the excess of welfare benefits - over the cap (SI 2013/376 reg 81), so a capped award stays flat and no - award before deductions may fall. Deductions are taken from the award as - percentages of the standard allowance (SI 2013/380 Sch 6), so the amount - paid may fall, but by no more than the deductions rise. In the model a - unit's deductions are its ``uc_deduction_rate`` times the standard - allowance, capped by the award; the rate does not depend on the - allowance, so it must not change, and deductions may rise by no more - than that rate times the allowance rise. (The rate can exceed 1: the - excess above the deductions cap, for deductions the cap does not limit, - has no bound of its own.) + ``base`` and ``reformed`` hold ``UC_ALLOWANCE_VARIABLES`` per benefit unit. + 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 and no award before deductions may + fall. That holds in the model, which applies no sanctions; a sanction is + taken after the cap and set by the standard allowance (regs 110 and 111), so + in law a sanctioned capped award can fall. Deductions are taken from the + award as percentages of the standard allowance (SI 2013/380 Sch 6), so the + amount paid may fall, but by no more than the deductions rise. Advances are + recovered as fixed amounts (SI 2013/383 reg 10), so the model's deduction + rise is an upper bound there (policyengine-uk#2410). In the model a unit's + deductions are its ``uc_deduction_rate`` times the standard allowance, + capped by the award; the rate does not depend on the allowance, so it must + not change, and deductions may rise by no more than that rate times the + allowance rise. (The rate can exceed 1: the excess above the deductions cap, + for deductions the cap does not limit, has no bound of its own.) """ allowance, base_allowance = ( From a088fd2c250fedd005f1b755ad5b763ea9586d3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 11:00:56 -0400 Subject: [PATCH 5/7] Check the award and the payment accounting, not deduction amounts Round-1 review of #614 and the full contract run on policyengine-uk 2.130.1 show that the deduction-rate assertions encoded the model's formula, not the law, and fail lawful cases: - deductions start with a new award (56 units on the full 1.58.1 file whose rate goes from 0 because their baseline award was nil); - the one-penny minimum payable binds (53 units); - in law advances are fixed amounts (SI 2013/383 reg 10), so no allowance-proportional bound is an invariant of both law and model. The check now requires: - no award before deductions falls by more than a penny (float32 rounding in these sums is a few thousandths of a pound; the round-1 review found the two "falling awards" on 2.126.12 were 0.0039 a year); - each payment moves by its award's change less its deductions' change, to the penny (signed, so a missing deduction release is caught); - exact 10% scaling and at least one higher payment, as before. How much deductions rise is documented as unchecked here. On the full 1.58.1 file both 2.126.12 and 2.130.1 hold; tests pin the penny boundary. Co-Authored-By: Claude Opus 5.5 --- .../uk/budget_reform_population.py | 75 +++++++++---------- tests/test_uk_budget_reform_contract.py | 73 ++++++++++-------- 2 files changed, 76 insertions(+), 72 deletions(-) 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 d99c2b8c..2fda433d 100644 --- a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py +++ b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py @@ -33,10 +33,8 @@ 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 bounds that - fall: no payment falls by more than its deductions rise, no unit's - deduction rate changes, and no deductions rise by more than that rate - times the standard allowance rise. + 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 @@ -110,9 +108,13 @@ def population_check_routes(routes: Sequence[str]) -> tuple[tuple[str, str], ... "universal_credit_pre_benefit_cap", "benefit_cap_reduction", "uc_deductions", - "uc_deduction_rate", ) +#: 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 + def uc_allowance_verdict( base: dict[str, np.ndarray], reformed: dict[str, np.ndarray] @@ -120,21 +122,26 @@ def uc_allowance_verdict( """Whether a 10% higher standard allowance moved Universal Credit lawfully. ``base`` and ``reformed`` hold ``UC_ALLOWANCE_VARIABLES`` per benefit unit. - 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 and no award before deductions may - fall. That holds in the model, which applies no sanctions; a sanction is - taken after the cap and set by the standard allowance (regs 110 and 111), so - in law a sanctioned capped award can fall. Deductions are taken from the - award as percentages of the standard allowance (SI 2013/380 Sch 6), so the - amount paid may fall, but by no more than the deductions rise. Advances are - recovered as fixed amounts (SI 2013/383 reg 10), so the model's deduction - rise is an upper bound there (policyengine-uk#2410). In the model a unit's - deductions are its ``uc_deduction_rate`` times the standard allowance, - capped by the award; the rate does not depend on the allowance, so it must - not change, and deductions may rise by no more than that rate times the - allowance rise. (The rate can exceed 1: the excess above the deductions cap, - for deductions the cap does not limit, has no bound of its own.) + + 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. + + 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. """ allowance, base_allowance = ( @@ -149,35 +156,23 @@ def uc_allowance_verdict( base["universal_credit_pre_benefit_cap"] - base["benefit_cap_reduction"] ) paid, base_paid = reformed["universal_credit"], base["universal_credit"] - deduction_rise = reformed["uc_deductions"] - base["uc_deductions"] - rate = reformed["uc_deduction_rate"] - rates_changed = int( - np.sum(~np.isclose(rate, base["uc_deduction_rate"], rtol=1e-9, atol=1e-12)) - ) - awards_lower = int(np.sum(award < base_award - 1e-3)) - payments_higher = int(np.sum(paid > base_paid + 1e-3)) - beyond_deductions = int( - np.sum((base_paid - paid) - np.maximum(deduction_rise, 0) > 1e-3) - ) - beyond_rate = int( - np.sum(deduction_rise - rate * (allowance - base_allowance) > 1e-3) - ) + 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)) holds = ( scaled and awards_lower == 0 - and beyond_deductions == 0 - and rates_changed == 0 - and beyond_rate == 0 + and payments_unaccounted == 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 - 1e-3)), + "payments_lower": int(np.sum(paid < base_paid - PENNY)), "payments_higher": payments_higher, - "payment_falls_beyond_deduction_rises": beyond_deductions, - "deduction_rates_changed": rates_changed, - "deduction_rises_beyond_rate_times_allowance_rise": beyond_rate, + "payments_not_award_less_deductions": payments_unaccounted, } diff --git a/tests/test_uk_budget_reform_contract.py b/tests/test_uk_budget_reform_contract.py index 591262a6..968111a3 100644 --- a/tests/test_uk_budget_reform_contract.py +++ b/tests/test_uk_budget_reform_contract.py @@ -634,7 +634,6 @@ def _uc_units(**columns): "universal_credit_pre_benefit_cap": [500.0], "benefit_cap_reduction": [0.0], "uc_deductions": [0.0], - "uc_deduction_rate": [0.0], } defaults.update(columns) return {name: np.array(values, dtype=float) for name, values in defaults.items()} @@ -649,14 +648,13 @@ def _verdict(base, reformed): def _lawful_pair(): - """A capped unit with deductions and an uncapped unit that gains.""" + """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], - uc_deduction_rate=[0.05, 0.0], universal_credit=[780.0, 500.0], ) reformed = _uc_units( @@ -664,38 +662,43 @@ def _lawful_pair(): universal_credit_pre_benefit_cap=[940.0, 540.0], benefit_cap_reduction=[140.0, 0.0], uc_deductions=[22.0, 0.0], - uc_deduction_rate=[0.05, 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; SI 2013/380 Sch 6 - deductions scale with the allowance, so the amount paid can fall.""" + """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.""" - base, reformed = _lawful_pair() - holds, detail = _verdict(base, reformed) + 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}, "awards_lower", 0), - ( - {"universal_credit": 770.0}, - "payment_falls_beyond_deduction_rises", - 0, - ), + ({"benefit_cap_reduction": 140.011}, "awards_lower", 0), + ({"universal_credit": 777.989}, "payments_not_award_less_deductions", 0), + ({"universal_credit": 778.011}, "payments_not_award_less_deductions", 0), ( - {"uc_deductions": 30.0, "universal_credit": 770.0}, - "deduction_rises_beyond_rate_times_allowance_rise", + {"uc_deductions": 0.0, "universal_credit": 780.0}, + "payments_not_award_less_deductions", 0, ), - ({"uc_deduction_rate": 0.06}, "deduction_rates_changed", 0), ( {"uc_standard_allowance": 430.0}, "standard_allowance_scaled_10_percent", @@ -703,10 +706,10 @@ def test_a_capped_award_whose_deductions_rise_with_the_allowance_holds(): ), ], ids=[ - "award-falls", - "payment-falls-beyond-deductions", - "deductions-beyond-rate", - "rate-changed", + "award-falls-past-a-penny", + "payment-below-award-less-deductions", + "payment-above-award-less-deductions", + "deductions-released-but-payment-unchanged", "not-scaled", ], ) @@ -722,18 +725,24 @@ def test_the_uc_allowance_check_fails_on_each_unlawful_movement( 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_a_deduction_rate_above_one_bounds_by_the_rate_not_the_allowance(): - """Deductions the cap does not limit can exceed the standard allowance.""" - - base, reformed = _lawful_pair() - base["uc_deduction_rate"][0] = reformed["uc_deduction_rate"][0] = 1.5 - base["uc_deductions"][0], reformed["uc_deductions"][0] = 600.0, 660.0 - base["universal_credit"][0], reformed["universal_credit"][0] = 200.0, 140.0 - holds, detail = _verdict(base, reformed) - assert holds, detail From 27f4003e1c5a32af62eef057dcb9587ac7f1c76a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 15:15:13 -0400 Subject: [PATCH 6/7] Keep the population stub's UC payment equal to award less deductions #612's population stub raised universal_credit under the UC allowance reform but not universal_credit_pre_benefit_cap, so on this branch the payment no longer equalled the award less deductions and #614's accounting assertion rightly failed three coverage tests. The stub now raises the award with the payment. Co-Authored-By: Claude Opus 5.5 --- tests/test_uk_budget_reform_contract.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_uk_budget_reform_contract.py b/tests/test_uk_budget_reform_contract.py index e25ed8ed..a9fdf6ef 100644 --- a/tests/test_uk_budget_reform_contract.py +++ b/tests/test_uk_budget_reform_contract.py @@ -512,7 +512,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 ( From c0e3e70925609601c7a17d4c4487329b111ede9b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 22:50:37 -0400 Subject: [PATCH 7/7] Protect the payable floor and require finite values in the UC check Round-2 review of #614: - Medium: deductions may not reduce the amount payable below a penny an assessment period (SI 2013/380 Sch 6 para 3(1)(a)). The check now requires every payment, in both runs, to be at least the lesser of its award and 12p a year, so deductions that take a whole award fail. - Medium: every value the check reads must be finite in both runs; a NaN deduction, cap reduction or award no longer passes the comparisons. - Low: the award-decline case lowers the award and its payment together, so only the award condition catches it; new tests cover errors that cancel across units, the penny boundary on gains, the 10% scaling tolerance and a payment left at exactly the floor. Removing any one of the four conditions fails a test. - Low: the population stub has no cap reduction or deductions, so its payments equal its awards at every level. - #612 review Low: assertion ids are tested on extraction-error, pending and unsupported rows too. On the full 1.58.1 file with policyengine-uk 2.130.1 the check holds: 0 award falls, 0 accounting breaks, 0 payments below the floor, 0 non-finite values. All 66 runner tests pass. Co-Authored-By: Claude Opus 5.5 --- .../uk/budget_reform_population.py | 36 ++++++- tests/test_uk_budget_reform_contract.py | 98 ++++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) 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 02ea0734..bc0955c0 100644 --- a/src/policyengine/tax_benefit_models/uk/budget_reform_population.py +++ b/src/policyengine/tax_benefit_models/uk/budget_reform_population.py @@ -143,6 +143,10 @@ def population_check_routes(routes: Sequence[str]) -> tuple[tuple[str, str], ... #: 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] @@ -164,6 +168,11 @@ def uc_allowance_verdict( (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 @@ -172,6 +181,18 @@ def uc_allowance_verdict( 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"], @@ -189,10 +210,21 @@ def uc_allowance_verdict( 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 = ( - scaled + non_finite == 0 + and scaled and awards_lower == 0 and payments_unaccounted == 0 + and payments_below_floor == 0 and payments_higher > 0 ) return holds, { @@ -201,6 +233,8 @@ def uc_allowance_verdict( "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, } diff --git a/tests/test_uk_budget_reform_contract.py b/tests/test_uk_budget_reform_contract.py index a9fdf6ef..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 @@ -867,7 +870,22 @@ def test_how_much_deductions_rise_is_not_bounded(rise): @pytest.mark.parametrize( ("change", "failing_key", "passing_value"), [ - ({"benefit_cap_reduction": 140.011}, "awards_lower", 0), + ( + {"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), ( @@ -882,7 +900,10 @@ def test_how_much_deductions_rise_is_not_bounded(rise): ), ], ids=[ - "award-falls-past-a-penny", + "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", @@ -922,3 +943,76 @@ 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"