Skip to content

Cap the eligible rent at the LHA before the Housing Benefit taper - #1926

Merged
juaristi22 merged 5 commits into
mainfrom
hb-lha-cap-before-taper
Oct 2, 2026
Merged

juaristi22 merged 5 commits into
mainfrom
hb-lha-cap-before-taper

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1917

Problem

housing_benefit_entitlement took the taper off the rent, capped the result at the Local Housing Allowance, and then took off non-dependant deductions: max(0, min(max(0, rent - taper), LHA_cap) - non_dep_deductions). When the rent exceeded the LHA by at least the taper, the cap absorbed the whole taper. When the excess was smaller, the cap still cut the taper by the excess.

2026, applicable income and amount set as inputs main this PR
Single aged 70, private renter, rent £30,000, LHA £9,467.64, taper 0.65 × (£14,948.80 − £13,312) = £1,063.92 £9,467.64 £8,403.72
Rent £12,000, LHA £9,467.64, taper £5,200 £6,800.00 £4,267.64
Rent £30,000, LHA £9,467.64, taper £13,000 £9,467.64 £0
Couple, rent £9,000, LHA £8,000, taper £2,724.93, non-dependant deductions £3,346.20 (rulespec-uk pipeline case 4) £2,928.87 £1,928.87
Rent £8,000 below the LHA, taper £1,063.92 £6,936.08 £6,936.08 (unchanged)
Council tenant with the case 4 numbers (LHA does not apply) £2,928.87 £2,928.87 (unchanged)

The first row is also reproduced end to end from household inputs (a single 70-year-old with State Pension £16,000 and no Guarantee Credit): housing_benefit is £9,467.64 on main and £8,403.72 here.

Law

Current revised texts, read on legislation.gov.uk (CLML data.xml) on 2026-09-30.

  • SI 2006/213 reg 12D(2)(a) (same words in SI 2006/214 reg 12D(2)(a)): where a maximum rent (LHA) is determined, "the amount of a person's eligible rent shall be the maximum rent (LHA)".
  • SI 2006/213 reg 13D(5) (and SI 2006/214 reg 13D(5)): "Where the applicable local housing allowance exceeds the cap rent, the maximum rent (LHA) shall be the cap rent." The cap rent is, by reg 13D(12), "the aggregate of such payments specified in regulation 12(1) (rent) which the claimant is liable to pay". So the maximum rent (LHA) is the lower of the LHA and the cap rent, which LHA_cap models as min_(benunit_rent, BRMA_LHA_rate).
  • SI 2006/213 reg 70 (and SI 2006/214 reg 50): "The amount of a person's appropriate maximum housing benefit in any week shall be 100 per cent. of his eligible rent calculated on a weekly basis ... less any deductions in respect of non-dependants".
  • SSCBA 1992 s.130(3)(b): "if his income exceeds the applicable amount, the amount of the housing benefit shall be what remains after the deduction from the appropriate maximum housing benefit of prescribed percentages of the excess of his income over the applicable amount". SI 2006/213 reg 71 and SI 2006/214 reg 51 set that percentage at "65 per cent".
  • Northern Ireland is the same, with different numbering: SR 2006/405 reg 13C(2)(a) (eligible rent is the maximum rent (LHA)), reg 14D(5) (cap rent), reg 68 (maximum HB is eligible rent and rates less non-dependant deductions) and reg 69(b) ("in calculating the amount of a rent rebate or allowance, 65 per cent."), under SSCB(NI)A 1992 s.129(3)(b); pension age SR 2006/406 regs 13C(2)(a), 14D(5), 48 and 49(b). NI households use the same LHA path in the model (the LHA rent list includes Northern Ireland BRMAs).

So the award is max(0, eligible rent − non-dependant deductions − taper), with eligible rent min(rent, LHA) for LHA tenants: the cap comes first, then the deductions and the taper.

Change

housing_benefit_entitlement:

eligible_rent = where(lha_eligible, LHA_cap, benunit_rent)                # reg 12D(2)(a), 13D(5)
maximum_housing_benefit = eligible_rent - housing_benefit_non_dep_deductions  # reg 70
taper = max_(0, applicable_income - applicable_amount) * withdrawal_rate      # s.130(3)(b), reg 71
return max_(0, maximum_housing_benefit - taper)

Social (non-LHA) tenants get the same result as before: for them the old formula reduces to max(0, rent − taper − deductions). The variable now has a documentation string and references to all the provisions above (GB working age and pension age, and NI).

Also:

  • housing_benefit_entitlement.yaml, "Reduced rent with LHA": the expectation was the capped-after-taper value. Rent 8,000, LHA_cap 1,000, taper 0.65 × (11,000 − 1,000) = 6,500: the law gives max(0, 1,000 − 6,500) = 0; main gave min(8,000 − 6,500, 1,000) = 1,000. The other three cases there are unchanged.
  • The Housing Benefit docs page (stages 1 and 3 of "How PolicyEngine computes Housing Benefit") said the eligible rent is benunit_rent capped at the LHA and described the taper against the rent. It now describes the cap, the maximum benefit and the taper in the legal order.

Other places checked for the same post-taper cap, none found:

  • Council Tax Reduction (simulated_council_tax_reduction_benunit, _legacy.py): max(0, liability × maximum support − taper − non-dependant deductions), no rent cap.
  • Universal Credit (uc_housing_costs_element): caps the rent at uc_LHA_cap inside the housing element, before the award-level taper.
  • housing_benefit_baseline_entitlement only reads the baseline simulation's housing_benefit_entitlement, so it follows this change.

Invariants

For every benefit unit, with taper = 0.65 × max(0, applicable income − applicable amount):

  1. 0 ≤ HB ≤ eligible rent ≤ rent; for LHA tenants HB ≤ LHA_cap = min(rent, LHA rate).
  2. HB = max(0, eligible rent − non-dependant deductions − taper).
  3. HB is non-increasing in applicable income and non-dependant deductions, and non-decreasing in rent, the LHA rate and the applicable amount.
  4. For an LHA tenant whose rent is at or above the LHA rate, more rent changes nothing.
  5. Differential against the old order: the two agree for social tenants, where the rent is at or below the LHA, where there is no taper, and where the old award was 0. Otherwise the new award is lower, by exactly min(rent − LHA_cap, taper, old award) (derived in the test docstring; intended).
  6. End to end, for pension-age renters with and without a working-age non-dependant, housing_benefit satisfies invariant 2 in the model's own applicable income, applicable amount and non-dependant deductions.

Invariants 1, 2 and 5 also hold on every one of the 61,223 benefit units in the Enhanced FRS runs below (checked in the diagnostics script).

Tests

  • New housing_benefit_lha_order.yaml, 9 cases, every expectation computed by hand with the arithmetic in comments: the Bug case at unit level and end to end from household inputs; rent below the LHA; a taper between the rent excess and the LHA; a taper above the LHA; no taper; rulespec-uk pipeline case 4; a council tenant with case 4's numbers; deductions above the LHA. Applicable income and amount are inputs, so the Housing Benefit earnings disregard and the Guarantee Credit passport (both being fixed separately) cannot move any expectation. The end-to-end case also sets BRMA_LHA_rate to 9,467.64, the value the model computes for that household in 2026, so it pins the order and not the LHA table.
  • New test_housing_benefit_lha_order_properties.py: Hypothesis (derandomize=True), invariants 1-6, three tests:
    • 15 generated populations of 10-40 benefit units, for the bounds, the closed form and the differential;
    • 10 populations, each with five perturbed copies, for monotonicity;
    • 10 end-to-end populations of 5-20 pension-age households, some with a working-age non-dependant.
  • Each test also runs an explicit example where the orders differ, with the arithmetic worked by hand in comments:
    • for the unit-level tests, one unit for each term of the minimum in invariant 5: the taper binds (the bug case), the rent excess binds (rent 12,000), and the old award binds (the new award is 0, the old 275.07);
    • end to end: the single pensioner from the issue, alone and with a working-age non-dependant, and a pension-age couple.
  • The strategies start the LHA and the applicable amount at realistic levels. Hypothesis favours small draws, and an LHA of a few pounds would leave every tapered award at 0, where the two orders agree.
  • Coverage of the examples the tests actually run, counted by wrapping the real test functions:
    • unit level: 12 of 273 units in 6 of the 16 populations fall where the orders differ (5 bound by the taper, 6 by the rent excess, 1 by the old award);
    • end to end: 10 of 88 eligible households have rent above the LHA, a positive taper and HB above 0.
  • The file takes about a minute of CPU (55.6 s user and 7.3 s system, including the import).
  • Against main (the old formula), 6 of the 13 YAML cases in the two entitlement files fail: the five new cases where the orders differ, and the corrected old case. All three property tests fail on their explicit examples.
  • Runs on this branch (local, 2026-09-30):
    • targeted YAML (every file under finance/benefit/family/housing_benefit/ and gov/dwp/pension_credit/, plus uc_legacy_mutual_exclusion.yaml): 122 passed, 0 failed, re-run on the head 02aa1eb;
    • full YAML suite (policyengine-core test policyengine_uk/tests/policy -c policyengine_uk): 1,285 passed, 0 failed, on 3a45d23. Later commits change only comments, docs, the changelog and the property tests;
    • property tests: 3 passed, re-run on 02aa1eb;
    • non-microsimulation pytest suite (-m "not microsimulation"): 318 passed, 15 skipped, 30 deselected (local, in the builder's worktree; the log does not record the commit).
  • CI Test: passed on ee18b85, the head before the changelog-only commit (run 36728861200); on the head 02aa1eb it is queued (run 36739417546).
  • ruff format --check . is clean.

Dataset impact (Enhanced FRS 2024-25, current weights, real runs of main vs this branch)

2026 main this PR change
Housing Benefit £14.227bn £14.225bn −£2.0m
HB caseload (benefit units) 1,711.7k 1,711.7k 0
of which wholly pension-age £13.843bn £13.841bn −£1.6m
mixed-age £0.200bn £0.200bn 0
working-age £0.184bn £0.184bn −£0.3m
UC / Pension Credit / Council Tax Reduction / benefit cap reduction £79.32bn / £6.99bn / £2.35bn / £1.18bn unchanged 0
Household net income −£2.0m (= HB change)
AHC poverty, pensioners 10.814% 10.818% +0.004pp
AHC poverty, all / BHC poverty, all 18.664% / 14.357% 18.665% / 14.357% +0.001pp / 0

Who moves (benefit units receiving HB on main, weighted):

all pension-age mixed-age working-age
HB recipients 1,711.7k (1,279 records) 1,664.4k 19.3k 28.0k
LHA tenants 564.3k (178) 547.8k 13.0k 3.6k
rent above the LHA 370.4k (92) 367.4k 1.1k 1.8k
… with a positive taper: lose HB 1.8k (12) 1.4k (8) 0 0.4k (4)
… with no taper: unchanged 368.6k (80) 366.0k 1.1k 1.4k

Only those who have the LHA applied, pay rent above it and have income above the applicable amount can lose, and all 12 of those records do. They are six pairs of records with identical inputs and different weights. Nobody gains, and nobody loses all their HB. The loss is the taper for 10 records (1.6k weighted) and the excess of rent over the LHA for 2 (0.2k). The effect is small because most HB recipients who pay rent above the LHA have no taper. For 334.1k of the 368.6k (52 records) the model computes Guarantee Credit above zero and housing_benefit_applicable_income is 0, which is what the current Guarantee Credit passport, unchanged here, gives (it sets applicable income to 0 when someone in the unit is over State Pension age and guarantee_credit > 0). Of those, 48 records (332.9k) receive Pension Credit and 4 (1.2k) do not (see "Not in this PR"). The rest have applicable income at or below the applicable amount.

housing_benefit_entitlement also changes for 655 records (654k weighted, 593 of them working-age) that do not receive HB (not eligible or not claiming), so housing_benefit does not change for them.

Caveat: the Enhanced FRS weights were calibrated to an HB total under the old code. The figures are from impact/dataset_impact.py and impact/compare_impact.py on a read-only copy of enhanced_frs_2024_25.h5; the breakdown is from impact/diag_lha.py, which also checks that main's entitlement equals the old formula, this branch's equals the new one, and old − new = min(rent − LHA_cap, taper, old) on every benefit unit.

Not in this PR

axiom: SI 2006/213 regs 12D(2)(a), 70 and 71 (and SI 2006/214 regs 50-51) — TheAxiomFoundation/rulespec-uk#370 queued

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits September 30, 2026 08:02
housing_benefit_entitlement took the taper off the rent, capped the result
at the Local Housing Allowance, then subtracted non-dependant deductions:
max(0, min(max(0, rent - taper), LHA_cap) - deductions). Whenever the rent
exceeded the LHA by more than the taper, the cap swallowed the taper, so a
private tenant with income above the applicable amount kept the full LHA.

Where a maximum rent (LHA) applies it is the eligible rent (SI 2006/213 and
SI 2006/214 reg 12D(2)(a); NI SR 2006/405 and 2006/406 reg 13C(2)(a)), and
it is the rent where the rent is lower (reg 13D(5); NI reg 14D(5)). The
appropriate maximum Housing Benefit is the eligible rent less non-dependant
deductions (regs 70 and 50; NI 68 and 48), and 65% of the excess of income
over the applicable amount is deducted from it (SSCBA 1992 s.130(3)(b),
regs 71 and 51; NI s.129(3)(b), regs 69(b) and 49(b)). The award is now
max(0, eligible rent - deductions - taper), with eligible rent LHA_cap for
LHA tenants and the rent otherwise. Social tenants are unchanged.

A single 70-year-old private renter with rent 30,000, LHA 9,467.64 and a
taper of 1,063.92 now gets 8,403.72 instead of 9,467.64. The existing
"Reduced rent with LHA" test expected the capped-after-taper 1,000; the law
gives max(0, 1,000 - 6,500) = 0.

Adds YAML cases (including rulespec-uk pipeline case 4: 2,928.87 becomes
1,928.87), Hypothesis property tests for the bounds, closed form,
monotonicity and the exact difference from the old order, and corrects the
Housing Benefit docs page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-to-end property now adds a working-age non-dependant to some
pension-age households, so the closed form is checked against the model's
own non-dependant deductions as well as its applicable income and amount.
The changelog and the Housing Benefit docs page name the Northern Ireland
provisions (SR 2006/405 regs 13C, 14D, 68 and 69; SR 2006/406 regs 13C,
14D, 48 and 49), and the changelog says the old order cut the taper by the
excess of the rent over the LHA, removing it only when that excess was at
least the taper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ffer

Hypothesis favours small draws, so the generated LHA rates were often a few
pounds and the taper removed every award, where capping before and after
the taper agree. In the derandomized run only 1 of 76 generated units fell
where the orders differ, and the end-to-end test passed against the old
formula.

The LHA and the applicable amount now start at realistic levels, the
populations hold 10 to 40 units (5 to 20 households end to end), and each
test runs an explicit example with a unit for each term of
old - new = min(rent - LHA_cap, taper, old), worked by hand. The end-to-end
example is the single pensioner from the issue, alone and with a
working-age non-dependant, and a pension-age couple. All three tests now
fail against the old order. Example counts drop to 15, 10 and 10 to keep
the file near a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Reviewed: approve with nits. The new order is max(0, where(LHA, min(rent, LHA), rent) − non-dependant deductions − 0.65 × excess). It matches regs 12D(2)(a) and 13D(5) (SI 2006/213 and 2006/214), reg 70 and SI 2006/214 reg 50, and regs 71 and 51 under SSCBA s.130(3)(b).

The council-tenant result is unchanged (I checked the algebra), and zero rent, rent below the LHA and non-dependant deductions above the rent all floor at 0. I hand-checked all 9 YAML cases. Locally the HB and Pension Credit YAML tests pass (120) and the property tests pass (3). Merged with #1927, 136 pass.

  1. Nit: the label in LHA_cap.py:12, "Applicable amount for LHA", is now wrong for what the variable holds. Rename it "Maximum rent (LHA)".
  2. Nit: the end-to-end case in housing_benefit_lha_order.yaml:39 sets state_pension_reported: 16_000 and also inputs applicable income. Add a comment that the input overrides the computed income.
  3. Nit: the description still says the pytest counts "will be added".
  4. Pre-existing: the separate taper on rates in Northern Ireland (SR 2006/405 reg 69(a)) isn't modelled.

The aggregate is small, about −£2m.

MaxGhenis and others added 2 commits September 30, 2026 11:47
The fragment was a paragraph; docs/engineering/skills/github-prs.md asks
for a short entry. It now says what changed and cites the GB working-age
and pension-age provisions. The PR body keeps the detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LHA_cap is min(rent, the BRMA LHA rate): the maximum rent (LHA) of SI
2006/213 and 2006/214 reg 13D(5), not an applicable amount. In the end
to end LHA-order case, say that the applicable income input overrides
the income computed from the State Pension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Thanks, Vahid. All four points are handled:

  1. LHA_cap label: now "Maximum rent (LHA)". Its documentation cites reg 13D(5).
  2. End-to-end case: commented that the applicable-income input overrides the income computed from the State Pension.
  3. Pytest placeholder: the description now has the counts.
  4. NI rates taper: listed under "Not in this PR".

Head 066cb2d. There is no change in behaviour since your review.

@juaristi22

Copy link
Copy Markdown
Collaborator

Codex review — ready to merge

Reviewed commit 066cb2dd6a3781d2af142ab54c6b7dee6c9a4aa7. COMPLETE; 0 confirmed blocking finding(s). Independent code/test and policy/source reviews were consolidated for the changed behavior and affected dependencies.

No actionable code/test defect found. housing_benefit_entitlement.py:45–66 now chooses eligible rent before subtracting non-dependant deductions and the means-test taper, and floors the result at zero. Private renters use min(rent,LHA); social renters use rent. The lower-rent, zero-taper and zero-award cases continue to agree with the old formula.

The property suite includes explicit examples at all three distinct bounds of the intended difference, monotonicity checks, and final Housing Benefit for pensioner households with and without non-dependants. These exercise the change beyond a copied formula oracle. Confidence: high.

No confirmed policy defect in the changed rent-cap/taper order.

The current formula first determines eligible rent (LHA_cap for an LHA case), subtracts nondependant deductions, then subtracts65% of positive excess income and floors the result at zero. HB regs13D(5),70 and71 and the pension-age regs12D(2),50 and51 require that ordering. NI regs14D(5),48 and49(b) make the equivalent distinction for rent. In particular, high rent must not absorb the income taper: the cap applies to maximum eligible rent before the taper. Non-LHA rent behavior remains algebraically unchanged.

Sources: GB LHA cap, GB maximum/taper, 65%, pension-age eligible rent, NI rent taper.

The LHA-cap label and explanatory test/body nits are addressed at066cb2dd. The separate NI rates rebate20% taper is outside this changed rent calculation and is pre-existing; do not count it as a regression introduced here. LHA transitional-protection/category rules are likewise not newly implemented by this ordering fix. Coordinate subsequent shared-file changes without dropping this order.

Validation: 16 passed, 1 warning. Changed implementation and relevant final-award/dependency tests were checked at this exact commit. 9 official source originals were inspected and their cached bytes verified. No material review gaps remain within this scope; private population-impact claims were not independently rerun.

Focused tests used an existing cached Python 3.13 environment (Core 3.32.9, NumPy 2.1.3, pandas 2.3.1, microdf-python 1.2.1, Hypothesis 6.168.2, pytest 8.4.2) with this PR’s isolated source snapshot. No dependencies were installed; this was not a freshly synced lock environment or a full-suite/population run. This is a review comment, not a formal GitHub review vote.

Live check before posting (2026-10-02T10:41:05.635577+00:00): same commit, CLEAN; all reported CI checks pass.

@juaristi22
juaristi22 merged commit 40cc541 into main Oct 2, 2026
7 checks passed
@juaristi22
juaristi22 deleted the hb-lha-cap-before-taper branch October 2, 2026 11:41
MaxGhenis added a commit that referenced this pull request Oct 2, 2026
…-capital-derived-income

Resolve the four means-test income formulas: keep #1896's claimant/partner
member filter and this branch's income-from-capital rules (no property
income, legacy_means_test_income_tax in the tax line, home-letting income).
Keep #1952's Pension Credit references and this branch's Sch IV para 18
reference, and #1926's HB docs with this branch's sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Housing Benefit applies the LHA cap after the taper instead of to the eligible rent

3 participants