Skip to content

Fix benefit cap exemption defects (#1818) - #1820

Open
vahid-ahmadi wants to merge 1 commit into
mainfrom
fix/benefit-cap-exemption-defects
Open

Fix benefit cap exemption defects (#1818)#1820
vahid-ahmadi wants to merge 1 commit into
mainfrom
fix/benefit-cap-exemption-defects

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

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_earnings computed:

uc_earned = benunit.sum(
    employment_income + self_employment_income - income_tax - national_insurance
)

income_tax is 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:

any relievable pension contributions made by the person in that period; any amounts paid by the person in that period in respect of the employment by way of income tax or primary Class 1 contributions…

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:

earnings = add(benunit, period, ["employment_income", "self_employment_income"])
deductions = add(
    benunit,
    period,
    ["earned_income_tax", "national_insurance", "pension_contributions"],
)
net_earnings = max_(0, earnings - deductions)

Worked example (2025-26): a claimant with £13,000 employment income and £40,000 property income.

Before After
Tax deducted £8,232 (total income tax) £86 (earned_income_tax)
NI deducted £34 £34
Net earnings £4,734 £12,880
Threshold (£846 × 12) £10,152 £10,152
Excepted from cap? No Yes

Variables considered and rejected

  • uc_earned_income — the natural candidate, but it subtracts uc_work_allowance, which is a Universal Credit means-test disregard and forms no part of the reg. 82 test. It also builds on uc_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_taxadds = ["tax"], i.e. total income tax plus NI, so it carries the same defect being fixed here.

Approximation accepted

earned_income_tax is the tax on non-savings, non-dividend income. Property, savings and dividend income are all excluded from it (see gov/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 in is_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:

the amount of earnings that a person would be paid at the hourly rate set out in regulation 4 of the National Minimum Wage Regulations for 16 hours per week, converted to a monthly amount by multiplying by 52 and dividing by 12

Now a dated parameter, gov.dwp.benefit_cap_earnings_exemption, expressed monthly as both the regulations and DWP express it, with label, unit: currency-GBP, period: month and legislative references.

Tax year NLW × 16 × 52 ÷ 12 Published threshold
2020-21 £8.72 £604.59 £604
2021-22 £8.91 £617.76 £617
2022-23 £9.50 £658.67 £658
2023-24 £10.42 £722.45 £722
2024-25 £11.44 £793.17 £793
2025-26 £12.21 £846.56 £846
2026-27 £12.71 £881.23 £881

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_apprentice top-bracket values.

⚠️ Direction of effect: MORE households capped. Raising the 2026-27 threshold from £846 to £881 a month removes exceptions from claimants earning between the two figures. This moves against the known over-capping in #1818 — it will make the 222k figure slightly worse for 2026-27, not better. It is nevertheless the correct statutory value. For 2025-26 the parameter resolves to £846, exactly reproducing the previous hardcoded behaviour, so 2025 results are unchanged.

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_payment exists in variables/gov/dwp/afip.py and is already used by the maintenance loan and council tax reduction logic, but no is_benefit_cap_exempt* file referenced it — they checked afcs (Armed Forces Compensation Scheme) only. Added to QUAL_PERSONAL_BENEFITS in is_benefit_cap_exempt_health_disability.

⚠️ No numeric change in microsimulation. armed_forces_independence_payment is a pure input variable: it has no formula, no adds, and no dataset in policyengine-uk-data populates 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_earnings computed roughly 45 lines and used one (meets_earnings_test); is_benefit_cap_exempt_other computed roughly 40 and used three (has_pensioner | afcs | esa_support_component). The giveaway is that is_benefit_cap_exempt_health_disability lists carer_support_payment among 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_exempt and is_benefit_cap_exempt_earnings both 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 covering

  • the earnings test with large unearned income present, in both directions (proving Defect 1)
  • combined earnings for a couple
  • relievable pension contributions removing an exception
  • the parameterised threshold, with the same £10,300 of earnings excepting in 2025-26 but not in 2026-27 (proving Defect 2)
  • AFIP excepting the benefit unit, for both an adult and a child, with a control case (proving Defect 3)
  • the state pension age and AFCS exceptions, unchanged

Results:

uv run policyengine-core test policyengine_uk/tests/policy -c policyengine_uk
1130 passed        # 1118 before this PR, + 12 new; no pre-existing test changed

uv run pytest policyengine_uk/tests/ -q -m "not microsimulation" --ignore=policyengine_uk/tests/policy
149 passed, 15 skipped, 28 deselected        # identical to the pre-change baseline

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

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>
@vahid-ahmadi
vahid-ahmadi requested a review from MaxGhenis August 13, 2026 13:05
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

@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 Test job passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant