Fix benefit cap exemption defects (#1818) - #1820
Conversation
Four defects in the benefit cap exception logic: 1. The earnings exception subtracted total income tax liability from earnings, so tax on property, savings, dividend and pension income reduced measured net earnings. Regulation 55(5) of the Universal Credit Regulations 2013 deducts only the tax paid in respect of the employment, primary Class 1 NI and relievable pension contributions. Now uses earned_income_tax, national_insurance and pension_contributions. 2. The earnings threshold was hardcoded as 10_152 in three files. Moved to the dated parameter gov.dwp.benefit_cap_earnings_exemption, expressed monthly as reg. 82 and DWP do, with values for 2020-21 to 2026-27 derived from 16 hours a week at the National Living Wage. 3. Armed Forces Independence Payment is a statutory exception but was never checked. Added to the health/disability qualifying benefits. 4. Removed dead copy-pasted code from all three is_benefit_cap_exempt_* variables, keeping each one's return semantics unchanged apart from defect 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@MaxGhenis review request — benefit cap exemption defects found while investigating #1818. The thing to decide is timing, not correctness. The main fix (using earnings-attributable tax rather than total income tax in the reg. 82 test) reduces capping and moves with the known over-capping. But parameterising the stale earnings threshold raises it from GBP 846 to GBP 881/month for 2026-27, which removes exemptions and increases capping — correct by law, wrong direction for #1818. 2025 is unchanged, since the new parameter resolves to GBP 846 exactly. So this makes 2026 capping modestly worse while #1818 is open. It is still right, but you may prefer it lands alongside the calibration work rather than before it. Worth knowing: AFIP produces no numeric change. The variable has no formula and nothing in policyengine-uk-data populates it. The exemption is legally correct to add, but nobody should expect the number to move. I would rather that were stated than discovered later. The dead-code removal keeps return semantics byte-identical apart from AFIP, and no pre-existing policy test changed (1118 → 1130, all additions) — which was the signal I asked to watch for. Gated |
Fixes four defects in the benefit cap exception logic, found while investigating #1818 (the model caps 222k UC households against DWP's 111k for Great Britain, November 2025).
These are real, confirmed defects, but none of them is the dominant cause of the over-capping — that is believed to lie in calibration and rents. Two of the four fixes push capping down, one pushes it up, and the net effect on the microsimulation caseload is expected to be small.
Defect 1 — the earnings test subtracted the wrong tax
is_benefit_cap_exempt_earningscomputed:income_taxis the total income tax liability across every source. Tax on property, savings, dividend and pension income was therefore being deducted from earnings.Regulation 55(5) of the Universal Credit Regulations 2013 deducts from employed earnings only:
So the test should net off the tax on the earnings, plus NI, plus relievable pension contributions — not the claimant's whole tax bill.
The fix reuses existing model variables rather than hand-rolling a new one:
Worked example (2025-26): a claimant with £13,000 employment income and £40,000 property income.
earned_income_tax)Variables considered and rejected
uc_earned_income— the natural candidate, but it subtractsuc_work_allowance, which is a Universal Credit means-test disregard and forms no part of the reg. 82 test. It also builds onuc_mif_capped_earned_income, and reg. 82 expressly excludes income treated as earned under the minimum income floor (reg. 62). Using it would understate reg. 82 earned income and remove exceptions.benunit_tax—adds = ["tax"], i.e. total income tax plus NI, so it carries the same defect being fixed here.Approximation accepted
earned_income_taxis the tax on non-savings, non-dividend income. Property, savings and dividend income are all excluded from it (seegov/hmrc/income_tax/earned_taxable_income_exclusions.yaml), which is what matters here. It does still include tax on private and state pension income, which UC treats as unearned. This is the residual approximation, and it is a narrow one: benefit units with meaningful pension income are generally already excepted through the state pension age exception inis_benefit_cap_exempt_other. Splitting tax by income source exactly would require an apportionment the model does not currently express.Direction of effect: fewer households capped. This one moves with the known over-capping.
Deducting relievable pension contributions is new and moves the other way (a pension contribution can now take a claimant below the threshold), but it is what reg. 55(5)(a) requires.
Defect 2 — the earnings threshold was hardcoded and stale
earnings_threshold = 10_152(£846/month, the 2025-26 value) appeared literally in all three exception files. Reg. 82 sets it as:Now a dated parameter,
gov.dwp.benefit_cap_earnings_exemption, expressed monthly as both the regulations and DWP express it, withlabel,unit: currency-GBP,period: monthand legislative references.Each value is the formula result rounded down to whole pounds, and each matches the figure DWP published for that year. The NLW rates are the model's own
gov.hmrc.minimum_wage.non_apprenticetop-bracket values.Sources: https://www.gov.uk/benefit-cap/when-youre-not-affected, https://www.legislation.gov.uk/uksi/2013/376/regulation/82.
Defect 3 — Armed Forces Independence Payment was never applied
AFIP is listed on GOV.UK as an exempting benefit. The variable
armed_forces_independence_paymentexists invariables/gov/dwp/afip.pyand is already used by the maintenance loan and council tax reduction logic, but nois_benefit_cap_exempt*file referenced it — they checkedafcs(Armed Forces Compensation Scheme) only. Added toQUAL_PERSONAL_BENEFITSinis_benefit_cap_exempt_health_disability.armed_forces_independence_paymentis a pure input variable: it has no formula, noadds, and no dataset inpolicyengine-uk-datapopulates it. It is therefore always zero in microsimulation, and this fix changes results only for household-level calculations where a user supplies the value directly (the API, the web app, or a test). Nobody should expect the capped-household count to move because of it.Direction of effect: fewer households capped, in principle; zero in practice until the variable is populated.
Defect 4 — dead code
The three files were stale copy-paste forks of one another.
is_benefit_cap_exempt_earningscomputed roughly 45 lines and used one (meets_earnings_test);is_benefit_cap_exempt_othercomputed roughly 40 and used three (has_pensioner | afcs | esa_support_component). The giveaway is thatis_benefit_cap_exempt_health_disabilitylistscarer_support_paymentamong its qualifying benefits while the two copies do not — a fix applied to one fork and not the others, which had no effect in the forks because the list was never returned.Removed the unreachable computation from all three files. Each file's return semantics are unchanged apart from Defect 3's addition, which is why no existing test changes. Also corrected two copy-pasted labels (
is_benefit_cap_exemptandis_benefit_cap_exempt_earningsboth carried the wrong file's label); labels are metadata and affect no calculation.Direction of effect: none.
Tests
New file:
policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml, 12 cases coveringResults:
Microsimulation tests were not run — the dataset is gated in this environment — so the aggregate effect on the capped-household count is unmeasured here and should be checked against the full dataset before drawing conclusions about #1818.
Refs #1818.
🤖 Generated with Claude Code