From b8f11fba06d7fa2a81c59c553ca0a6777cf66141 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:44:00 +0100 Subject: [PATCH 1/4] Reconcile UC deduction and benefit cap components under the protected floor The protected minimum floor was applied only when computing universal_credit, so uc_deductions and benefit_cap_reduction reported their unfloored values: a floor of 0.85 with a 1,000 GBP benefit cap reduction and a 0.30 latent rate reported 960.34 + 1,000 GBP against a 720.25 GBP reduction actually applied. The floor now lives in the components. uc_maximum_reduction holds the allowance the floor leaves; uc_deductions is limited only where deductions alone exceed it, and the new uc_benefit_cap_reduction takes what remains. That ordering matches JRF's worked example, which preserves the deduction and eliminates the cap reduction, and it means the components sum to the entitlement forgone. benefit_cap_reduction stays gross because Housing Benefit uses it and the UC floor does not protect Housing Benefit, which also keeps it out of a dependency cycle. Also clamps the floor allowance at zero (a floor above 1 made it negative and added money to the award), gates uc_deductions on would_claim_uc for the dataset-imputed migration path, and corrects the 25% cap date to 30 April 2021 and the one-penny citation to Schedule 6 paragraph 3(1)(a) of SI 2013/380. Current-law results are unchanged: the floor is zero, so the allowance is infinite and both components pass through unaltered. Co-Authored-By: Claude Opus 5 (1M context) --- .../uc-deductions-floor-reconciliation.md | 5 + docs/book/validation/uc-deductions.md | 16 +++- .../dwp/universal_credit/deductions/cap.yaml | 4 +- .../deductions/minimum_payable.yaml | 6 +- .../deductions/protected_floor.yaml | 2 +- policyengine_uk/tests/test_uc_deductions.py | 94 ++++++++++++++++++- .../deductions/uc_deductions.py | 31 ++++-- .../deductions/uc_maximum_reduction.py | 35 +++++++ .../uc_benefit_cap_reduction.py | 36 +++++++ .../dwp/universal_credit/universal_credit.py | 30 ++---- 10 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 changelog.d/uc-deductions-floor-reconciliation.md create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py diff --git a/changelog.d/uc-deductions-floor-reconciliation.md b/changelog.d/uc-deductions-floor-reconciliation.md new file mode 100644 index 0000000000..b60d47e56b --- /dev/null +++ b/changelog.d/uc-deductions-floor-reconciliation.md @@ -0,0 +1,5 @@ +- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. +- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor, leaving `benefit_cap_reduction` gross for Housing Benefit. +- Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. +- Fixed Universal Credit deductions applying to benefit units that would not claim Universal Credit. +- Corrected the effective date of the 25% deductions cap to 30 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index 5d7d4fc4a4..c84be05510 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -12,12 +12,14 @@ The implementation separates **latent demand** from the **statutory cap**. `uc_l Enhanced FRS 2023/24 v1.40.3, against [DWP's Universal Credit deductions statistics](https://www.gov.uk/government/statistics/universal-credit-quarterly-statistics-29-april-2013-to-12-february-2026/universal-credit-deductions-statistics-march-2025-to-february-2026). +Shares below are of **all** UC households, not of those with deductions — the denominator both the model and DWP use. + | Statistic | Model | DWP published | |---|---|---| | Share of UC households with deductions | 47.0–47.1% | 46–47% | -| At-cap share, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | -| At-cap share, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | -| Above-cap share (last resort) | 1.8% | 2% | +| At-cap share of all UC households, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | +| At-cap share of all UC households, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | +| Above-cap share of all UC households (last resort) | 1.8% | 2% | | Mean monthly deduction, 25% regime | £66 | £67–68 | | Mean monthly deduction, 15% regime | £50 | £51–54 | | Mean annual gain, 25%→15% cap | £421 | £420 (gov.uk) | @@ -47,7 +49,11 @@ The latent distribution is observed in 2025 and applied to all model years as a ### The protected floor binds on last resort deductions -The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. It binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. +The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. The benefit cap reduction absorbs the floor first: `uc_deductions` is cut only where deductions alone still exceed the allowance. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions essentially never breach the floor on their own — only benefit cap reductions push past it. JRF's own worked example (standard allowance £92, deduction £14, benefit cap £59, floor £78) works out the same way: the £14 deduction survives in full and the £59 cap reduction goes. + +Where deductions alone do exceed the allowance, the floor binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. + +Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone. `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. ### Other assumptions @@ -75,7 +81,7 @@ The `protected_floor` lever limits combined deductions and benefit cap reduction {"gov.dwp.universal_credit.deductions.protected_floor": {"2026-01-01.2030-12-31": 0.85}} ``` -A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from. +A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because the cap reduction absorbs the floor first, where nearly all of the modelled effect lands. ## Where the assignment lives diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml index 9a62d98a81..7cc392ffbb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml @@ -1,8 +1,8 @@ -description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). +description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 30 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). values: 2015-04-01: 0.4 2019-10-01: 0.3 - 2021-04-01: 0.25 + 2021-04-30: 0.25 2025-04-30: 0.15 metadata: label: UC deductions cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml index 075b067fe6..47e5da327b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml @@ -1,4 +1,4 @@ -description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6 of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. +description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6, paragraph 3(1)(a) of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. values: 2015-04-01: 0.01 metadata: @@ -6,5 +6,5 @@ metadata: unit: currency-GBP period: month reference: - - title: SI 2013/380, Schedule 6 (deductions from benefit and direct payment to third parties) - href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6 + - title: SI 2013/380, Schedule 6, paragraph 3(1)(a) (deductions must leave at least one penny payable) + href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6/paragraph/3 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml index 50fbf3aca3..0da2c48b45 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml @@ -1,4 +1,4 @@ -description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The modeled floor binds on all deductions, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. +description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The benefit cap reduction absorbs the floor first (uc_benefit_cap_reduction), so deductions are limited only where they alone exceed the allowance - matching JRF's worked example, which preserves the deduction in full and eliminates the benefit cap reduction. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. values: 2015-04-01: 0 metadata: diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index ffab86dfe2..373d79894f 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -161,9 +161,9 @@ def test_protected_floor_inactive_when_reductions_stay_above_it(self): ) def test_protected_floor_binds_on_the_above_cap_excess(self): - # Latent demand of 30% is 15% cappable plus a 15% last resort excess - # that current law exempts from the cap, giving a 20% deduction rate - # under the 15% cap. The modeled floor limits combined reductions + # Latent demand of 30% is 25% cappable (cut to 15% by the cap) plus a + # 5% last resort excess that current law exempts from the cap, giving + # a 20% deduction rate. The modeled floor limits combined reductions # regardless of category, so it cuts into that excess. JRF's briefing # does not say whether their floor exempts last resort and child # maintenance deductions; this pins the modeling choice. @@ -186,6 +186,94 @@ def test_protected_floor_binds_on_the_above_cap_excess(self): 6_000 - 0.15 * sa ) + def test_floor_components_reconcile_with_the_reduction_applied(self): + # uc_deductions and uc_benefit_cap_reduction are what the award + # actually loses, so they must sum to the entitlement forgone. Before + # the floor was pushed into the components, they reported £960 and + # £1,000 against a £720 reduction actually applied. + situation = make_situation( + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + deductions = reform.calculate("uc_deductions", YEAR)[0] + cap_reduction = reform.calculate("uc_benefit_cap_reduction", YEAR)[0] + universal_credit = reform.calculate("universal_credit", YEAR)[0] + assert deductions + cap_reduction == pytest.approx(0.15 * sa) + assert deductions + cap_reduction == pytest.approx(6_000 - universal_credit) + # The gross benefit cap reduction is unchanged: it also drives + # Housing Benefit, which the Universal Credit floor does not protect. + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): + # JRF's worked example (standard allowance £92, deduction £14, + # benefit cap £59, floor £78) keeps the £14 deduction whole and + # eliminates the £59 cap reduction. Deductions of 10% of the standard + # allowance fit inside a 15% floor allowance, so they survive intact + # and the cap reduction takes the whole remaining squeeze. + situation = make_situation( + benefit_cap_reduction={YEAR: 2_500}, + uc_latent_deduction_rate={YEAR: 0.10}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.10 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == pytest.approx( + 0.05 * sa + ) + + def test_a_floor_above_the_standard_allowance_adds_nothing(self): + # (1 - floor) x standard allowance goes negative above a floor of 1; + # subtracting it added £960 to a £6,000 award. The allowance clamps + # at zero, where the floor protects the standard allowance entirely. + situation = make_situation( + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 1.2 + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx(6_000) + + def test_no_deductions_without_a_universal_credit_claim(self): + # Datasets are meant to impute uc_latent_deduction_rate directly, at + # which point nothing else gates deductions on the claim. + sim = Simulation( + situation=make_situation( + would_claim_uc={YEAR: False}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + ) + assert sim.calculate("universal_credit", YEAR)[0] == 0 + assert sim.calculate("uc_deductions", YEAR)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + def test_zeroing_every_type_share_leaves_a_valid_enum_array(self): situation = make_situation( uc_deduction_random_draw={YEAR: 0.0}, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py index 0ef750fec5..9cf7698b3e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py @@ -7,18 +7,20 @@ class uc_deductions(Variable): "Money deducted from this benefit unit's Universal Credit award to " "repay debts: advance repayments, government debt (benefit and tax " "credit overpayments) and third party deductions. Deductions leave " - "at least one penny per assessment period payable (Schedule 6 of " - "SI 2013/380). Per-household statistics are validated against the " - "DWP deductions statistics; weighted aggregates are not, because " - "they scale with the model's UC caseload, which falls short of the " - "administrative count. Annualized from the surveyed monthly rate: " - "for deduction types with spells shorter than a year (e.g. advance " - "recovery), annual per-household amounts are upper bounds." + "at least one penny per assessment period payable (Schedule 6, " + "paragraph 3(1)(a) of SI 2013/380). Per-household statistics are " + "validated against the DWP deductions statistics; weighted " + "aggregates are not, because they scale with the model's UC " + "caseload, which falls short of the administrative count. " + "Annualized from the surveyed monthly rate: for deduction types " + "with spells shorter than a year (e.g. advance recovery), annual " + "per-household amounts are upper bounds." ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + defined_for = "would_claim_uc" def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.deductions @@ -30,4 +32,17 @@ def formula(benunit, period, parameters): 0, ) minimum_payable = p.minimum_payable * MONTHS_IN_YEAR - return min_(rate * standard_allowance, max_(award - minimum_payable, 0)) + deductions = min_(rate * standard_allowance, max_(award - minimum_payable, 0)) + # The protected minimum floor (zero, and so inactive, under current + # law) limits deductions and benefit cap reductions combined. The + # benefit cap reduction absorbs it first, so deductions bend only + # where they alone exceed the floor allowance: under the Fair + # Repayment Rate the 15% deductions cap equals a typical 15% floor + # allowance, so cappable deductions essentially never breach the + # floor on their own. The floor does bind on the above-cap excess + # that current law exempts from the deductions cap (last resort and + # child maintenance deductions). JRF's briefing does not say whether + # their floor exempts those categories - its worked example involves + # only cappable deductions and the benefit cap - so this is a + # modeling choice, tracked for follow-up. + return min_(deductions, benunit("uc_maximum_reduction", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py new file mode 100644 index 0000000000..82e37fa76d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class uc_maximum_reduction(Variable): + label = "UC maximum combined reduction" + documentation = ( + "Most that benefit cap reductions and deductions combined may take " + "off a Universal Credit award under the protected minimum floor: " + "(1 - floor) x the standard allowance. Infinite when the floor is " + "zero - the value under current law, which has no such floor - so " + "reductions are unlimited." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + + def formula(benunit, period, parameters): + # A protected minimum floor (zero under current law) caps combined + # benefit cap reductions and deductions at (1 - floor) x the standard + # allowance, per JRF's protected minimum floor design (their worked + # example limits the reduction itself to 15% of the standard + # allowance). Zero means reductions are unlimited. + floor_rate = parameters( + period + ).gov.dwp.universal_credit.deductions.protected_floor + standard_allowance = benunit("uc_standard_allowance", period) + # A floor above 1 would make the allowance negative; subtracting a + # negative reduction would add money to the award. Clamp at zero, + # where the floor protects the whole standard allowance. + return where( + floor_rate > 0, + max_((1 - floor_rate) * standard_allowance, 0), + np.inf, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py new file mode 100644 index 0000000000..081dc56c67 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_reduction(Variable): + label = "UC benefit cap reduction" + documentation = ( + "Benefit cap reduction actually taken off this benefit unit's " + "Universal Credit award. Equal to benefit_cap_reduction under " + "current law. Where a protected minimum floor is in force, the cap " + "reduction absorbs the floor first, so only the part of it fitting " + "in the allowance uc_deductions leaves is applied. " + "benefit_cap_reduction itself stays gross, because it also applies " + "to Housing Benefit, which the Universal Credit floor does not " + "protect." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + defined_for = "would_claim_uc" + + def formula(benunit, period, parameters): + reduction = benunit("benefit_cap_reduction", period) + # Deductions take their share of the floor allowance first, so the + # cap reduction is what bends when the floor binds. Under the Fair + # Repayment Rate the 15% deductions cap equals a typical 15% floor + # allowance, so cappable deductions alone essentially never breach + # the floor - only benefit cap reductions push past it. JRF's worked + # example (standard allowance £92, deduction £14, benefit cap £59, + # floor £78) preserves the £14 deduction and eliminates the £59 cap + # reduction, which is this ordering. + headroom = max_( + benunit("uc_maximum_reduction", period) - benunit("uc_deductions", period), + 0, + ) + return min_(reduction, headroom) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index f30621334a..5230ba6324 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -11,26 +11,12 @@ class universal_credit(Variable): def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) - benefit_cap_reduction = benunit("benefit_cap_reduction", period) + # Both components are already limited by the protected minimum floor + # (zero, and so inactive, under current law): uc_deductions takes its + # share of the floor allowance first and uc_benefit_cap_reduction + # takes what is left. Subtracting them separately, rather than + # limiting their sum here, keeps the reported components summing to + # the reduction actually applied. + benefit_cap_reduction = benunit("uc_benefit_cap_reduction", period) deductions = benunit("uc_deductions", period) - # A protected minimum floor (zero under current law) caps combined - # benefit cap reductions and deductions at (1 - floor) x the standard - # allowance, per JRF's protected minimum floor design (their worked - # example limits the reduction itself to 15% of the standard - # allowance). Zero means reductions are unlimited. - # The floor binds on the whole of uc_deductions, including the above- - # cap excess that current law exempts from the deductions cap (last - # resort and child maintenance deductions). JRF's briefing does not - # say whether their floor exempts those categories - its worked - # example involves only cappable deductions and the benefit cap - so - # this is a modeling choice, tracked for follow-up. - floor_rate = parameters( - period - ).gov.dwp.universal_credit.deductions.protected_floor - total_reductions = benefit_cap_reduction + deductions - max_reductions = where( - floor_rate > 0, - (1 - floor_rate) * benunit("uc_standard_allowance", period), - np.inf, - ) - return max_(uc_max_entitlement - min_(total_reductions, max_reductions), 0) + return max_(uc_max_entitlement - benefit_cap_reduction - deductions, 0) From d4396aca225ebd7ebaa6fb77da52823fd4d672fc Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:45:07 +0100 Subject: [PATCH 2/4] Cite Schedule 6 paragraph 3(1)(a) in the one-penny policy test comment Co-Authored-By: Claude Opus 5 (1M context) --- .../finance/benefit/family/universal_credit/uc_deductions.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml index 1840c13910..23e9f370d2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml @@ -107,7 +107,8 @@ household: members: person output: - # Schedule 6 of SI 2013/380: the award cannot be deducted below one + # Schedule 6, paragraph 3(1)(a) of SI 2013/380: the award cannot be + # deducted below one # penny per assessment period (12p per year). uc_deductions: 499.88 universal_credit: 0.12 From 3753e83eebd76c2c3aa48addf704da301f0ab110 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 08:22:05 +0200 Subject: [PATCH 3/4] Give the changelog fragment a towncrier type Co-Authored-By: Claude Opus 5.5 --- ...onciliation.md => uc-deductions-floor-reconciliation.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{uc-deductions-floor-reconciliation.md => uc-deductions-floor-reconciliation.fixed.md} (100%) diff --git a/changelog.d/uc-deductions-floor-reconciliation.md b/changelog.d/uc-deductions-floor-reconciliation.fixed.md similarity index 100% rename from changelog.d/uc-deductions-floor-reconciliation.md rename to changelog.d/uc-deductions-floor-reconciliation.fixed.md From 656a0f8203a084c9a418123e3671e846ffe38832 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:47:59 +0200 Subject: [PATCH 4/4] Allocate UC deductions and the applied benefit cap through one helper under the protected floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The applied UC cap reduction is bounded by the award actually lost (a £1,000 gross reduction on a £500 award applied £720.25 under an 85% floor, and £1,000 with none). Where the floor binds, deductions are met first from the protected award, up to the floor allowance, and the cap reduction absorbs the rest; previously the penny rule squeezed deductions under the gross cap, so heavy capping moved the split. Where the floor does not bind, and under current law, values are unchanged and universal_credit keeps main's float order. The 25% deductions cap dates from 12 April 2021 (DWP Annual Report 2020-21). The JRF reference moves to the September 2026 briefing, with a test of its example. Co-Authored-By: Claude Opus 5.5 --- ...c-deductions-floor-reconciliation.fixed.md | 6 +- docs/book/validation/uc-deductions.md | 8 +- .../dwp/universal_credit/deductions/cap.yaml | 6 +- .../deductions/protected_floor.yaml | 6 +- policyengine_uk/tests/test_uc_deductions.py | 96 ++++++++++++++++++- .../deductions/_reduction_allocation.py | 61 ++++++++++++ .../deductions/uc_deductions.py | 37 ++++--- .../uc_benefit_cap_reduction.py | 46 +++++---- .../dwp/universal_credit/universal_credit.py | 13 ++- 9 files changed, 216 insertions(+), 63 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py diff --git a/changelog.d/uc-deductions-floor-reconciliation.fixed.md b/changelog.d/uc-deductions-floor-reconciliation.fixed.md index b60d47e56b..68af97f62e 100644 --- a/changelog.d/uc-deductions-floor-reconciliation.fixed.md +++ b/changelog.d/uc-deductions-floor-reconciliation.fixed.md @@ -1,5 +1,5 @@ -- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. -- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor, leaving `benefit_cap_reduction` gross for Housing Benefit. +- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. Where the floor binds, deductions are met first and the cap reduction absorbs the rest, however heavily the award is capped. +- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor and bounded by the award, leaving `benefit_cap_reduction` gross for Housing Benefit. - Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. - Fixed Universal Credit deductions applying to benefit units that would not claim Universal Credit. -- Corrected the effective date of the 25% deductions cap to 30 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. +- Corrected the effective date of the 25% deductions cap to 12 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index c84be05510..8314085a61 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -49,11 +49,13 @@ The latent distribution is observed in 2025 and applied to all model years as a ### The protected floor binds on last resort deductions -The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. The benefit cap reduction absorbs the floor first: `uc_deductions` is cut only where deductions alone still exceed the allowance. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions essentially never breach the floor on their own — only benefit cap reductions push past it. JRF's own worked example (standard allowance £92, deduction £14, benefit cap £59, floor £78) works out the same way: the £14 deduction survives in full and the £59 cap reduction goes. +The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. Where the current-law split already fits inside that allowance, nothing changes. Where it does not, deductions are met first from the protected award, up to the allowance, and the benefit cap reduction absorbs the rest. Both `uc_deductions` and `uc_benefit_cap_reduction` come from one allocation (`_reduction_allocation.py`), so they agree by construction. + +JRF specifies the combined limit, not a priority between the two: their [September 2026 briefing](https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty) proposes that "the sum of the 'capped amount' and any debt deductions is limited to the difference between a household's standard allowance and the level of the protected minimum floor". Debt first is the modeling choice, for three reasons. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions never breach the floor on their own, and the policy JRF builds on (extending the Fair Repayment Rate "to all deductions, including the benefit cap") leaves deductions where that rate puts them. JRF's own example (standard allowance £98 a week, a £15 deduction, an £80 cap reduction, total limited to £15) is consistent with it. And it does not depend on how heavily the household is capped: if the deduction were limited by the gross cap reduction, a household capped down to a few pence would have its deduction squeezed to almost nothing even though the floor then removes most of that cap. The award is the same under either priority; only the split differs. Where deductions alone do exceed the allowance, the floor binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. -Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone. `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. +Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone, and the applied cap reduction never exceeds the award (a £1,000 cap reduction on a £500 award applies £500). `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. ### Other assumptions @@ -81,7 +83,7 @@ Both components report what the award actually loses, so `uc_deductions` and `uc {"gov.dwp.universal_credit.deductions.protected_floor": {"2026-01-01.2030-12-31": 0.85}} ``` -A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because the cap reduction absorbs the floor first, where nearly all of the modelled effect lands. +A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because deductions are met first, where nearly all of the modeled effect lands. ## Where the assignment lives diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml index 7cc392ffbb..bfe577cc36 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml @@ -1,8 +1,8 @@ -description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 30 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). +description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 12 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021; DWP Annual Report and Accounts 2020-21), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). values: 2015-04-01: 0.4 2019-10-01: 0.3 - 2021-04-30: 0.25 + 2021-04-12: 0.25 2025-04-30: 0.15 metadata: label: UC deductions cap @@ -23,5 +23,7 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5e68d0dce90e070ac74f08ea/Budget_2020_Web_Accessible_Complete.pdf#page=73 - title: HM Treasury, Budget 2021 (25% cap brought forward to April 2021) href: https://www.gov.uk/government/publications/budget-2021-documents/budget-2021-html + - title: DWP Annual Report and Accounts 2020-21, p.57 (cap reduced from 30% to 25% from 12 April 2021) + href: https://assets.publishing.service.gov.uk/media/60fa97498fa8f5043411d83d/dwp-annual-report-and-accounts-2020-2021-web-ready.pdf#page=58 - title: Universal Credit change brings £420 boost to over a million households (Fair Repayment Rate, 30 April 2025) href: https://www.gov.uk/government/news/universal-credit-change-brings-420-boost-to-over-a-million-households diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml index 0da2c48b45..50f754caa9 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml @@ -1,4 +1,4 @@ -description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The benefit cap reduction absorbs the floor first (uc_benefit_cap_reduction), so deductions are limited only where they alone exceed the allowance - matching JRF's worked example, which preserves the deduction in full and eliminates the benefit cap reduction. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. +description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. Where the current-law split already fits inside the allowance nothing changes; where it does not, deductions are met first, up to the allowance, and the benefit cap reduction absorbs the rest (uc_benefit_cap_reduction). JRF fixes the combined limit, not the priority between the two, so this ordering is a modeling choice; it reproduces JRF's September 2026 example, where a 15% deduction survives and the cap reduction goes. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. values: 2015-04-01: 0 metadata: @@ -6,5 +6,5 @@ metadata: unit: /1 period: year reference: - - title: JRF, A protected minimum floor in Universal Credit (October 2025 briefing; proposal design source, not current law) - href: https://www.jrf.org.uk/social-security/protected-minimum-floor-in-universal-credit-new-policy-targeting-hardship + - title: JRF, A protected minimum floor in Universal Credit to cut deep child poverty (11 September 2026, replacing the 9 October 2025 briefing; proposal design source, not current law) + href: https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index 373d79894f..a012fb2c8b 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -9,6 +9,12 @@ YEAR = 2025 +FLOOR_85 = { + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } +} + def make_situation(**benunit_overrides): benunit = { @@ -215,11 +221,10 @@ def test_floor_components_reconcile_with_the_reduction_applied(self): assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): - # JRF's worked example (standard allowance £92, deduction £14, - # benefit cap £59, floor £78) keeps the £14 deduction whole and - # eliminates the £59 cap reduction. Deductions of 10% of the standard - # allowance fit inside a 15% floor allowance, so they survive intact - # and the cap reduction takes the whole remaining squeeze. + # Deductions of 10% of the standard allowance fit inside a 15% floor + # allowance, so they survive intact and the cap reduction keeps only + # the 5% left. test_jrf_2026_example_keeps_a_full_rate_deduction + # covers JRF's own example, where a 15% deduction fills the allowance. situation = make_situation( benefit_cap_reduction={YEAR: 2_500}, uc_latent_deduction_rate={YEAR: 0.10}, @@ -239,6 +244,87 @@ def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): 0.05 * sa ) + def test_jrf_2026_example_keeps_a_full_rate_deduction(self): + # JRF's September 2026 briefing: standard allowance £98 a week, a + # Fair Repayment Rate deduction of £15 and a benefit cap reduction of + # £80; an 85% floor limits the total reduction to £15 and the + # household gains £80. Latent demand of 25% is cut to the 15% cap, + # which exactly fills the floor allowance, so the deduction survives + # whole and the cap reduction falls to zero. + situation = make_situation( + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + sa = Simulation(situation=situation).calculate("uc_standard_allowance", YEAR)[0] + situation["benunits"]["benunit"]["benefit_cap_reduction"] = {YEAR: 80 / 98 * sa} + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == pytest.approx( + 0, abs=0.01 + ) + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + + @pytest.mark.parametrize("gross_cap_reduction", [5_500, 5_900]) + def test_heavy_capping_does_not_squeeze_the_deduction(self, gross_cap_reduction): + # Under the gross cap the penny rule leaves room for only £499.88 or + # £99.88 of deductions. The floor removes all but the allowance, so + # the deduction is met in full from the protected award and the cap + # reduction absorbs the floor: £720.25 and £0, not £499.88/£220.37 + # or £99.88/£620.37. + situation = make_situation( + benefit_cap_reduction={YEAR: gross_cap_reduction}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == gross_cap_reduction + + @pytest.mark.parametrize("floor", [0, 0.85]) + def test_applied_cap_never_exceeds_the_award_lost(self, floor): + # A £1,000 gross cap reduction on a £500 award takes £500, not £1,000 + # (no floor) or the £720.25 floor allowance. The gross figure, which + # Housing Benefit reads, is unchanged. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 500}, + benefit_cap_reduction={YEAR: 1_000}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": floor + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 500 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_floor_changes_nothing_where_it_does_not_bind(self): + # A £700 award, a £1,000 gross cap and deduction demand above the + # floor allowance: the cap already takes the whole award, which is + # less than the £720.25 allowance, so the floor leaves the current-law + # split alone rather than moving the loss from the cap to deductions. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 700}, + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 700 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + def test_a_floor_above_the_standard_allowance_adds_nothing(self): # (1 - floor) x standard allowance goes negative above a floor of 1; # subtracting it added £960 to a £6,000 award. The allowance clamps diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py new file mode 100644 index 0000000000..b22e6ff0f0 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py @@ -0,0 +1,61 @@ +"""Split a Universal Credit award's reductions between debt deductions and +the benefit cap. + +uc_deductions and uc_benefit_cap_reduction both call this, so they agree by +construction, and neither reads the other, which keeps the graph acyclic. +""" + +from policyengine_uk.model_api import * + + +def allocate_uc_reductions( + pre_cap_award, gross_cap_reduction, deduction_demand, max_reduction, minimum_payable +): + """Return (deductions, applied cap reduction) for each benefit unit. + + pre_cap_award: universal_credit_pre_benefit_cap. + gross_cap_reduction: benefit_cap_reduction, the regulation 81 amount of + SI 2013/376. Housing Benefit reads it too, so it is never floored. + deduction_demand: the deduction rate times the standard allowance, + before the one-penny rule. + max_reduction: uc_maximum_reduction, infinite when no protected floor is + in force (current law). + minimum_payable: the one-penny rule of Schedule 6 paragraph 3(1)(a) of + SI 2013/380, annualised. + + Current law: regulation 81 reduces the award by the cap first, and + deductions come out of what is left, leaving at least a penny payable + (main's operations, in main's order). The applied cap reduction is the + award actually lost, so it is bounded by the award; the gross reduction + can exceed it. + + Under a protected floor nothing changes unless that split takes more + than the floor allowance. Where it does, deductions are met first from + the protected award, up to the allowance, and the cap reduction takes + whatever allowance is left. Working from the deduction demand rather + than the current-law deduction matters for heavily capped awards: there + the penny rule had already squeezed the deduction under the gross cap, + but the floor removes most of that cap. + """ + floor_active = np.isfinite(max_reduction) + + # Current law. + award = max_(pre_cap_award - gross_cap_reduction, 0) + deductions = min_(deduction_demand, max_(award - minimum_payable, 0)) + cap_reduction = min_(gross_cap_reduction, max_(pre_cap_award - deductions, 0)) + + # Protected floor binding: deductions first, the cap reduction absorbs. + binds = floor_active & (cap_reduction + deductions > max_reduction) + floor_deductions = min_( + min_(deduction_demand, max_reduction), + max_(pre_cap_award - minimum_payable, 0), + ) + penny = where(floor_deductions > 0, minimum_payable, 0) + floor_cap_reduction = min_( + min_(gross_cap_reduction, max_(max_reduction - floor_deductions, 0)), + max_(pre_cap_award - floor_deductions - penny, 0), + ) + return ( + where(binds, floor_deductions, deductions), + where(binds, floor_cap_reduction, cap_reduction), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py index 9cf7698b3e..eb569c6cb4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) class uc_deductions(Variable): @@ -26,23 +29,19 @@ def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.deductions rate = benunit("uc_deduction_rate", period) standard_allowance = benunit("uc_standard_allowance", period) - award = max_( - benunit("universal_credit_pre_benefit_cap", period) - - benunit("benefit_cap_reduction", period), - 0, + # Shared with uc_benefit_cap_reduction. Under current law the cap + # reduces the award first and deductions leave a penny of what is + # left. Where a protected minimum floor binds, deductions are met + # first, up to the floor allowance, and the cap reduction absorbs the + # rest. The floor then also binds on the above-cap excess that + # current law exempts from the deductions cap (last resort and child + # maintenance deductions); JRF's briefing does not say whether their + # floor exempts those categories, so that is a modeling choice. + deductions, _ = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("benefit_cap_reduction", period), + rate * standard_allowance, + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, ) - minimum_payable = p.minimum_payable * MONTHS_IN_YEAR - deductions = min_(rate * standard_allowance, max_(award - minimum_payable, 0)) - # The protected minimum floor (zero, and so inactive, under current - # law) limits deductions and benefit cap reductions combined. The - # benefit cap reduction absorbs it first, so deductions bend only - # where they alone exceed the floor allowance: under the Fair - # Repayment Rate the 15% deductions cap equals a typical 15% floor - # allowance, so cappable deductions essentially never breach the - # floor on their own. The floor does bind on the above-cap excess - # that current law exempts from the deductions cap (last resort and - # child maintenance deductions). JRF's briefing does not say whether - # their floor exempts those categories - its worked example involves - # only cappable deductions and the benefit cap - so this is a - # modeling choice, tracked for follow-up. - return min_(deductions, benunit("uc_maximum_reduction", period)) + return deductions diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py index 081dc56c67..af45f75bc5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py @@ -1,17 +1,21 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) class uc_benefit_cap_reduction(Variable): - label = "UC benefit cap reduction" + label = "UC benefit cap reduction applied" documentation = ( "Benefit cap reduction actually taken off this benefit unit's " - "Universal Credit award. Equal to benefit_cap_reduction under " - "current law. Where a protected minimum floor is in force, the cap " - "reduction absorbs the floor first, so only the part of it fitting " - "in the allowance uc_deductions leaves is applied. " - "benefit_cap_reduction itself stays gross, because it also applies " - "to Housing Benefit, which the Universal Credit floor does not " - "protect." + "Universal Credit award. Under current law this is " + "benefit_cap_reduction, bounded by the award: a cap reduction larger " + "than the award takes the award to zero and no further. Where a " + "protected minimum floor binds, deductions are met first, up to the " + "floor allowance, and this takes whatever allowance is left. " + "benefit_cap_reduction itself stays gross, because Housing Benefit " + "also reads it and the Universal Credit floor does not protect " + "Housing Benefit." ) entity = BenUnit definition_period = YEAR @@ -20,17 +24,17 @@ class uc_benefit_cap_reduction(Variable): defined_for = "would_claim_uc" def formula(benunit, period, parameters): - reduction = benunit("benefit_cap_reduction", period) - # Deductions take their share of the floor allowance first, so the - # cap reduction is what bends when the floor binds. Under the Fair - # Repayment Rate the 15% deductions cap equals a typical 15% floor - # allowance, so cappable deductions alone essentially never breach - # the floor - only benefit cap reductions push past it. JRF's worked - # example (standard allowance £92, deduction £14, benefit cap £59, - # floor £78) preserves the £14 deduction and eliminates the £59 cap - # reduction, which is this ordering. - headroom = max_( - benunit("uc_maximum_reduction", period) - benunit("uc_deductions", period), - 0, + p = parameters(period).gov.dwp.universal_credit.deductions + # JRF's September 2026 example (standard allowance £98 a week, a £15 + # Fair Repayment Rate deduction, an £80 cap reduction, an 85% floor) + # limits the total to £15. Meeting the deduction first, as here, + # removes the whole cap reduction. + _, cap_reduction = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("benefit_cap_reduction", period), + benunit("uc_deduction_rate", period) + * benunit("uc_standard_allowance", period), + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, ) - return min_(reduction, headroom) + return cap_reduction diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index 5230ba6324..31bf7b86f6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -11,12 +11,11 @@ class universal_credit(Variable): def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) - # Both components are already limited by the protected minimum floor - # (zero, and so inactive, under current law): uc_deductions takes its - # share of the floor allowance first and uc_benefit_cap_reduction - # takes what is left. Subtracting them separately, rather than - # limiting their sum here, keeps the reported components summing to - # the reduction actually applied. + # Both components already reflect the protected minimum floor (zero, + # and so inactive, under current law), so the reported components sum + # to the reduction actually applied. Summing them before subtracting + # keeps main's float arithmetic, so current-law awards are unchanged + # to the bit. benefit_cap_reduction = benunit("uc_benefit_cap_reduction", period) deductions = benunit("uc_deductions", period) - return max_(uc_max_entitlement - benefit_cap_reduction - deductions, 0) + return max_(uc_max_entitlement - (benefit_cap_reduction + deductions), 0)