Reconcile UC deduction and benefit cap components under the protected floor - #1819
Reconcile UC deduction and benefit cap components under the protected floor#1819vahid-ahmadi wants to merge 2 commits into
Conversation
… 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verified the five fixes independently against the branch — all confirmed:
One residual worth documenting (not blocking)
The award is correct in every row and the components reconcile — this is strictly an attribution issue between the two components, not a UC error, so it is much smaller than the bug this PR fixes. But under the absorption ordering the deduction should survive whole at 720.25 throughout, and in the last two rows it does not. It bites only when Suggest either fixing it or noting it in the docs alongside the existing last-resort caveat. |
|
@MaxGhenis review request — this fixes bugs in the deductions work merged in #1815. The judgement call is the absorption ordering. The floor limits combined reductions; something has to give first. This PR makes the benefit cap reduction absorb the floor while the deduction survives, on the reasoning that under the FRR the deductions cap (15%) equals a typical floor allowance, so cappable deductions alone essentially never breach it. JRF's worked example supports this — their floor allowance is GBP 14/week and their deduction is GBP 14, so it survives whole and the GBP 59 cap reduction goes. The model reproduces that exactly (SA GBP 92.34/wk vs their GBP 92, allowance GBP 13.85 vs their GBP 14). If you read their design differently — proportional abatement, say — that changes the split though not the award. Three things worth your eye:
Current law is bit-identical (verified numerically), and the gated |
Five confirmed bugs in the merged UC deductions / protected floor code, plus the doc clarifications that follow from the first fix.
1. Deductions did not reconcile when the protected floor bound
universal_creditapplied the floor to the sum ofbenefit_cap_reductionanduc_deductionsat the point of computing the award. Neither component was rewritten, so under a floor reform both reported their unfloored values.Reproduction (floor 0.85,
benefit_cap_reduction£1,000, latent rate 0.30, standard allowance £4,801.70):uc_deductionsbenefit_cap_reduction)uc_benefit_cap_reduction)Fix. The floor now lives in the components rather than in the award formula:
uc_maximum_reductionholds the allowance the floor leaves —max((1 − floor) × standard allowance, 0), infinite when the floor is zero.uc_deductionsis limited to that allowance, so deductions bend only where they alone exceed it.uc_benefit_cap_reductiontakes the headroom deductions leave:min(benefit_cap_reduction, max(allowance − uc_deductions, 0)).universal_creditsubtracts the two components separately, so reconciliation holds by construction.Ordering: the benefit cap reduction absorbs the floor first. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions alone essentially never breach the floor — only benefit cap reductions push past it. JRF's own worked example (standard allowance £92, deduction £14, benefit cap £59, floor £78) preserves the £14 deduction and eliminates the £59 cap reduction, which is exactly this ordering. Test
test_the_benefit_cap_reduction_absorbs_the_floor_firstpins it.benefit_cap_reductionstays gross. It also drives Housing Benefit, which the UC floor does not protect, so flooring it would leak a UC reform into HB. Keeping it gross is also what avoids a dependency cycle:uc_deductionsanduc_has_deductionboth readbenefit_cap_reductionfor the one-penny award test, so flooring it in place would have madebenefit_cap_reduction → uc_deductions → benefit_cap_reduction. The new chain is acyclic —universal_credit → uc_benefit_cap_reduction → {benefit_cap_reduction, uc_maximum_reduction, uc_deductions}— and the full suites run clean, which is what would surface a cycle.Residual question, deliberately not changed. The above-cap "last resort" excess (last resort and child maintenance deductions), which current law exempts from the 15% deductions cap, is still subject to the floor where deductions alone exceed the allowance. 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 remains a modelling choice, documented in
protected_floor.yaml, inuc_deductions.py, in the validation page, and pinned bytest_protected_floor_binds_on_the_above_cap_excess.2. A floor above 1 manufactured money
(1 − floor) × standard allowancegoes negative above a floor of 1, and subtracting a negative reduction added to the award:protected_floor = 1.2on a £6,000 entitlement returned £6,960.34. The allowance now clamps at zero, where the floor protects the whole standard allowance from any reduction. Test:test_a_floor_above_the_standard_allowance_adds_nothing.3.
uc_deductionsapplied without a UC claimuc_deductionslackeddefined_for = "would_claim_uc". Withwould_claim_uc = Falseand an imputeduc_latent_deduction_rate,universal_creditwas £0 butuc_deductionswas £720.25. Harmless today because the fallbackuc_has_deductionformula gates on the claim, but it breaks the documented migration path where datasets impute the latent rate directly and the fallback retires. Guard added touc_deductionsand to the newuc_benefit_cap_reduction. Test:test_no_deductions_without_a_universal_credit_claim.4. Wrong comment in
test_protected_floor_binds_on_the_above_cap_excessIt read "Latent demand of 30% is 15% cappable plus a 15% last resort excess" — 15 + 15 = 30, but the test asserts a 20% rate. The real split is 25% cappable (the calibration cap), cut to 15% by the deductions cap, plus a 5% excess. Comment only; assertions were already right.
5. Parameter provenance
deductions/cap.yaml: the 25% cap moves from2021-04-01to2021-04-30, the actual change date and the convention the file already uses for the Fair Repayment Rate.deductions/minimum_payable.yamlanduc_deductions.py: the one-penny rule is Schedule 6 paragraph 3(1)(a) of SI 2013/380, not Schedule 6 generally.Validated statistics are unaffected
Microsimulation tests are gated on the dataset, so this is reasoned rather than run:
protected_flooris 0, souc_maximum_reductionis infinite,uc_deductionspasses throughmin(D, ∞) = D, anduc_benefit_cap_reductionismin(B, ∞) = B.universal_creditbecomesmax(pre − B − D, 0), which is whatmax(pre − min(B + D, ∞), 0)already evaluated to. Confirmed numerically: the unreformed case above returns deductions £960.34, cap reduction £1,000, UC £4,039.66 both before and after.defined_forguard changes nothing in the microdata.uc_deductionswas already zero without a claim, becauseuc_deduction_rate → uc_latent_deduction_ratereturns zero unlessuc_has_deduction, which requireswould_claim_uc. The guard only matters once datasets impute the latent rate directly.convert_to_fiscal_year_parameterssamples every parameter at 30 April of each year, andcap.yamlcarries nofiscal_year_blend, so 2021 reads 0.25 under either date. The validated years are 2024 and 2025 in any case.uc_deduction_rateare untouched.uc_has_deduction,uc_deduction_rateand the deduction draws are unchanged, so incidence, at-cap shares and mean amounts are unchanged.The one doc-side change is a label clarification: the validation table's at-cap and above-cap rows are shares of all UC households, which is what the model computes (
w[on_uc & …].sum() / uc_weightintest_uc_deductions_aggregates.py) and what DWP publishes. Figures are unchanged.Tests
policyengine_uk/tests/test_uc_deductions.py: 17 passed (4 new).policyengine_uk/tests/ -m "not microsimulation"(excludingtests/policy): 153 passed, 15 skipped.policyengine-core test policyengine_uk/tests/policy: 1118 passed.🤖 Generated with Claude Code