From 3a45d230320df20c97e73f544b501bbcbc7784e8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:02:38 -0400 Subject: [PATCH 1/5] Cap the eligible rent at the LHA before the Housing Benefit taper 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 --- changelog.d/hb-lha-cap-before-taper.fixed.md | 1 + .../programs/gov/dwp/housing-benefit.ipynb | 4 +- .../housing_benefit_entitlement.yaml | 6 +- .../housing_benefit_lha_order.yaml | 184 +++++++++++ ...st_housing_benefit_lha_order_properties.py | 292 ++++++++++++++++++ .../housing_benefit_entitlement.py | 50 ++- 6 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 changelog.d/hb-lha-cap-before-taper.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml create mode 100644 policyengine_uk/tests/test_housing_benefit_lha_order_properties.py diff --git a/changelog.d/hb-lha-cap-before-taper.fixed.md b/changelog.d/hb-lha-cap-before-taper.fixed.md new file mode 100644 index 0000000000..88d5d68a50 --- /dev/null +++ b/changelog.d/hb-lha-cap-before-taper.fixed.md @@ -0,0 +1 @@ +Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper, as SI 2006/213 regs 12D(2)(a), 70 and 71 (and SI 2006/214 regs 12D, 50 and 51) require. The taper is now deducted from the LHA rather than from the rent, so private tenants whose rent exceeds the LHA and whose income exceeds the applicable amount no longer receive the full LHA; previously the cap applied after the taper, which removed the taper whenever the rent exceeded the LHA by more than the taper. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 4f928b173d..b2129d879b 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -38,9 +38,9 @@ "\n", "The award is computed in four stages:\n", "\n", - "1. **Eligible rent** (`benunit_rent`): the contracted rent for the property, capped at the Local Housing Allowance rate where the LHA applies.\n", + "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate or, where the rent is lower, the rent (`LHA_cap`; [SI 2006/213 regs 12D(2)(a) and 13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D)).\n", "2. **Applicable amount** (`housing_benefit_applicable_amount`): a personal allowance derived from the benunit's composition (single, couple, lone parent) and age, plus any disability/carer premiums. This is the \"minimum income\" the household needs before HB tapers begin.\n", - "3. **Means test** (`housing_benefit_entitlement`): the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is tapered against the eligible rent at the published `withdrawal_rate` (currently 0.65). The result is then reduced by `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household.\n", + "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51.\n", "4. **Benefit cap** (`housing_benefit`): the post-means-test award is finally reduced by `benefit_cap_reduction` if the household's total benefit income exceeds the cap and no cap exemption applies.\n", "\n", "The take-up gate (`would_claim_housing_benefit`) is applied inside `housing_benefit_pre_benefit_cap`." diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml index 36ff0f8875..bfb2fb548c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml @@ -20,7 +20,11 @@ LHA_cap: 1_000 housing_benefit_non_dep_deductions: 0 output: - housing_benefit_entitlement: 1_000 + # The eligible rent is the LHA cap of 1,000 (SI 2006/213 reg 12D(2)(a)), + # and the taper of 0.65 * (11,000 - 1,000) = 6,500 is deducted from it + # (reg 71): max(0, 1,000 - 6,500) = 0. Capping after the taper gave + # min(8,000 - 6,500, 1,000) = 1,000. + housing_benefit_entitlement: 0 - name: Capped at 0 period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml new file mode 100644 index 0000000000..1dfe23a501 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml @@ -0,0 +1,184 @@ +# The Local Housing Allowance caps the eligible rent before the taper. +# Where a maximum rent (LHA) applies, it is the eligible rent (SI 2006/213 and +# SI 2006/214 reg 12D(2)(a)), and it is the rent where the rent is below the +# LHA (reg 13D(5)). The appropriate maximum Housing Benefit is the eligible rent +# less non-dependant deductions (reg 70; SPC reg 50), and 65% of the excess of +# income over the applicable amount is deducted from it (SSCBA 1992 +# s.130(3)(b); reg 71; SPC reg 51). So: +# HB = max(0, min(rent, LHA) - non-dependant deductions - taper) +# The code before this fix capped after the taper: +# max(0, min(max(0, rent - taper), LHA) - non-dependant deductions) +# +# Applicable income and amount are inputs, so the taper is exact and does not +# depend on the earnings disregard or the Guarantee Credit passport. +# LHA_cap is min(rent, LHA rate), as LHA_cap.py computes it. + +- name: Rent above the LHA is tapered from the LHA, not from the rent + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (14,948.80 - 13,312) = 0.65 * 1,636.80 = 1,063.92 + # HB = 9,467.64 - 1,063.92 = 8,403.72 + # (the old order gave min(30,000 - 1,063.92, 9,467.64) = 9,467.64) + housing_benefit_entitlement: 8_403.72 + +- name: Single pensioner private renter with rent above the LHA + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 16_000 + benunits: + benunit: + members: [person] + # The model's 2026 rate for this household (default BRMA, Maidstone, + # one-bedroom category) is also 9,467.64; it is an input here so the + # test pins the order of the cap and the taper, not the LHA table. + BRMA_LHA_rate: 9_467.64 + # Inputs, so the earnings disregard and the pension-age allowance + # cannot move the taper. + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + households: + household: + members: [person] + rent: 30_000 + tenure_type: RENT_PRIVATELY + output: + # State Pension of 16,000 is above the Guarantee Credit minimum guarantee, + # so no Guarantee Credit passport applies. + guarantee_credit: 0 + LHA_eligible: true + # LHA_cap = min(30,000, 9,467.64) = 9,467.64 + LHA_cap: 9_467.64 + housing_benefit_non_dep_deductions: 0 + # 9,467.64 - 0.65 * (14,948.80 - 13,312) = 9,467.64 - 1,063.92 = 8,403.72 + housing_benefit_eligible: true + housing_benefit: 8_403.72 + +- name: Rent below the LHA is the eligible rent + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 8_000 + LHA_eligible: true + # min(8,000, 9,467.64) = 8,000 + LHA_cap: 8_000 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + housing_benefit_non_dep_deductions: 0 + output: + # 8,000 - 1,063.92 = 6,936.08, as under the old order + housing_benefit_entitlement: 6_936.08 + +- name: Taper larger than the excess of the rent over the LHA + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 12_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 21_312 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (21,312 - 13,312) = 0.65 * 8,000 = 5,200 + # HB = 9,467.64 - 5,200 = 4,267.64 + # (the old order gave min(12,000 - 5,200, 9,467.64) = 6,800, which is + # 2,532.36 = rent - LHA more) + housing_benefit_entitlement: 4_267.64 + +- name: Taper larger than the LHA leaves no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 33_312 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (33,312 - 13,312) = 0.65 * 20,000 = 13,000 > 9,467.64 + # HB = max(0, 9,467.64 - 13,000) = 0 + # (the old order gave min(30,000 - 13,000, 9,467.64) = 9,467.64) + housing_benefit_entitlement: 0 + +- name: Income below the applicable amount gets the LHA in full + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 10_000 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * max(0, 10,000 - 13,312) = 0; HB = 9,467.64 + housing_benefit_entitlement: 9_467.64 + +- name: LHA couple with a non-dependant (rulespec-uk pipeline case 4) + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 9_000 + LHA_eligible: true + # min(9,000, LHA 8,000) = 8,000 + LHA_cap: 8_000 + housing_benefit_applicable_amount: 7_807.80 + housing_benefit_applicable_income: 12_000 + housing_benefit_non_dep_deductions: 3_346.20 + output: + # taper = 0.65 * (12,000 - 7,807.80) = 0.65 * 4,192.20 = 2,724.93 + # HB = 8,000 - 3,346.20 - 2,724.93 = 1,928.87 + # (the old order gave min(9,000 - 2,724.93, 8,000) - 3,346.20 + # = 6,275.07 - 3,346.20 = 2,928.87) + housing_benefit_entitlement: 1_928.87 + +- name: Council tenant with the same rent and taper is not capped + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: [person] + housing_benefit_applicable_amount: 7_807.80 + housing_benefit_applicable_income: 12_000 + housing_benefit_non_dep_deductions: 3_346.20 + households: + household: + members: [person] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + LHA_eligible: false + # eligible rent = rent = 9,000 + # HB = 9,000 - 3,346.20 - 2,724.93 = 2,928.87, as under the old order + housing_benefit_entitlement: 2_928.87 + +- name: Non-dependant deductions above the LHA leave no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 9_000 + LHA_eligible: true + LHA_cap: 8_000 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 10_000 + housing_benefit_non_dep_deductions: 8_500 + output: + # no taper; HB = max(0, 8,000 - 8,500) = 0 + housing_benefit_entitlement: 0 diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py new file mode 100644 index 0000000000..05cc05a394 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -0,0 +1,292 @@ +"""Property-based tests for the order of the LHA cap and the taper. + +Where a maximum rent (LHA) applies, it is the eligible rent (SI 2006/213 and +SI 2006/214 reg 12D(2)(a)), and it is the rent where the rent is below the +LHA (reg 13D(5)). The appropriate maximum Housing Benefit is the eligible rent +less non-dependant deductions (reg 70; SPC reg 50), and 65% of the excess of +income over the applicable amount is deducted from it (SSCBA 1992 +s.130(3)(b); reg 71; SPC reg 51). So, with taper = 0.65 * max(0, income - +applicable amount): + + HB = max(0, eligible rent - non-dependant deductions - taper) + +where the eligible rent is min(rent, LHA) for LHA tenants and the rent for +social tenants. + +Invariants, for any generated population of benefit units: + +1. Bounds: 0 <= HB <= eligible rent <= rent, and for LHA tenants + HB <= LHA_cap = min(rent, LHA rate). +2. Closed form: HB equals the formula above. +3. Monotonicity: HB is non-increasing in applicable income and in + non-dependant deductions, and non-decreasing in rent, the LHA rate and the + applicable amount. +4. Rent above the LHA: for an LHA tenant whose rent is at or above the LHA + rate, raising the rent further leaves HB unchanged. +5. Differential against the formula before this change, which capped after + the taper (max(0, min(max(0, rent - taper), LHA_cap) - deductions)): + 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, and old - new = min(rent - LHA_cap, taper, old) exactly. + This difference is intended. +6. End to end: for pension-age renters, housing_benefit satisfies the closed + form in the model's own applicable income and amount. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +# Engine values are float32; 0.05 covers rounding on amounts up to 100,000. +TOLERANCE = 0.05 +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +RENTED_TENURES = ["RENT_FROM_COUNCIL", "RENT_FROM_HA", "RENT_PRIVATELY"] +NON_INCREASING = ["income", "non_dep_deductions"] +NON_DECREASING = ["rent", "lha_rate", "applicable_amount"] + + +def money(high): + # Whole pence, so the arithmetic is exact to the penny. + return st.integers(0, high * 100).map(lambda pence: pence / 100) + + +def above(floor, high): + return st.integers(0, high * 100).map( + lambda pence: (round(floor * 100) + pence) / 100 + ) + + +@st.composite +def units(draw): + lha_rate = draw(money(30_000)) + applicable_amount = draw(money(25_000)) + return dict( + tenure=draw(st.sampled_from(RENTED_TENURES)), + lha_rate=lha_rate, + # Many draws put the rent above the LHA and the income above the + # applicable amount, where capping before or after the taper differs. + rent=draw(st.one_of(money(40_000), above(lha_rate, 20_000))), + applicable_amount=applicable_amount, + income=draw( + st.one_of(st.just(0.0), money(60_000), above(applicable_amount, 15_000)) + ), + non_dep_deductions=draw(st.one_of(st.just(0.0), money(10_000))), + ) + + +POPULATION = st.lists(units(), min_size=1, max_size=40) +BUMP = money(10_000).filter(lambda bump: bump > 0) + + +def calculate(population): + """Housing Benefit inputs and entitlement for independent benefit units. + + Each unit is a one-person benefit unit in its own household, so one + simulation can hold a population and its perturbed copies. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(population): + name = f"p{i}" + people[name] = {"age": {YEAR: 70}} + benunits[f"b{i}"] = { + "members": [name], + "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + "housing_benefit_applicable_amount": {YEAR: unit["applicable_amount"]}, + "housing_benefit_applicable_income": {YEAR: unit["income"]}, + "housing_benefit_non_dep_deductions": {YEAR: unit["non_dep_deductions"]}, + } + households[f"h{i}"] = { + "members": [name], + "rent": {YEAR: unit["rent"]}, + "tenure_type": {YEAR: unit["tenure"]}, + } + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + values = { + variable: np.asarray(sim.calculate(variable, YEAR), dtype=float) + for variable in [ + "housing_benefit_entitlement", + "benunit_rent", + "BRMA_LHA_rate", + "LHA_cap", + "LHA_eligible", + "housing_benefit_applicable_amount", + "housing_benefit_applicable_income", + "housing_benefit_non_dep_deductions", + ] + } + rate = sim.tax_benefit_system.parameters( + YEAR + ).gov.dwp.housing_benefit.means_test.withdrawal_rate + values["lha"] = values["LHA_eligible"].astype(bool) + values["taper"] = rate * np.maximum( + 0, + values["housing_benefit_applicable_income"] + - values["housing_benefit_applicable_amount"], + ) + values["eligible_rent"] = np.where( + values["lha"], values["LHA_cap"], values["benunit_rent"] + ) + return values + + +def old_entitlement(values): + """housing_benefit_entitlement as computed before this change.""" + final_amount = np.maximum(0, values["benunit_rent"] - values["taper"]) + capped = np.minimum(final_amount, values["LHA_cap"]) + amount = np.where(values["lha"], capped, final_amount) + return np.maximum(0, amount - values["housing_benefit_non_dep_deductions"]) + + +@PROPERTY_SETTINGS +@given(POPULATION) +def test_bounds_closed_form_and_differential(population): + values = calculate(population) + hb = values["housing_benefit_entitlement"] + rent = values["benunit_rent"] + lha = values["lha"] + tenures = np.array([unit["tenure"] for unit in population]) + # LHA tenants are exactly the private renters here. + assert np.array_equal(lha, tenures == "RENT_PRIVATELY") + assert np.allclose( + values["LHA_cap"], np.minimum(rent, values["BRMA_LHA_rate"]), atol=TOLERANCE + ) + + # 1. Bounds. + assert np.all(hb >= 0) + assert np.all(hb <= values["eligible_rent"] + TOLERANCE) + assert np.all(values["eligible_rent"] <= rent + TOLERANCE) + assert np.all(hb[lha] <= values["LHA_cap"][lha] + TOLERANCE) + + # 2. Closed form. + expected = np.maximum( + 0, + values["eligible_rent"] + - values["housing_benefit_non_dep_deductions"] + - values["taper"], + ) + assert np.allclose(hb, expected, atol=TOLERANCE), population + + # 5. Differential against capping after the taper. + old = old_entitlement(values) + rent_excess = rent - values["LHA_cap"] + agree = ~lha | (rent_excess <= 0) | (values["taper"] == 0) | (old == 0) + assert np.allclose(hb[agree], old[agree], atol=TOLERANCE), population + assert np.all(hb <= old + TOLERANCE) + # Intended difference: the taper now comes off the LHA, not the rent. + difference = np.where( + lha, np.minimum(np.minimum(rent_excess, values["taper"]), old), 0 + ) + assert np.allclose(old - hb, difference, atol=TOLERANCE), population + + +@settings(PROPERTY_SETTINGS, max_examples=15) +@given(POPULATION, st.lists(BUMP, min_size=5, max_size=5)) +def test_monotonicity(population, bumps): + fields = NON_INCREASING + NON_DECREASING + variants = [population] + [ + [{**unit, field: unit[field] + bump} for unit in population] + for field, bump in zip(fields, bumps) + ] + hb = calculate([unit for variant in variants for unit in variant])[ + "housing_benefit_entitlement" + ].reshape(len(variants), len(population)) + before = hb[0] + for after, field in zip(hb[1:], fields): + if field in NON_INCREASING: + assert np.all(after <= before + TOLERANCE), (population, field, bumps) + else: + assert np.all(after >= before - TOLERANCE), (population, field, bumps) + + # 4. For LHA tenants already at or above the LHA, more rent changes + # nothing: the eligible rent is the LHA. + above = np.array( + [ + unit["tenure"] == "RENT_PRIVATELY" and unit["rent"] >= unit["lha_rate"] + for unit in population + ] + ) + after_rent = hb[1 + fields.index("rent")] + assert np.allclose(after_rent[above], before[above], atol=TOLERANCE), ( + population, + bumps, + ) + + +@st.composite +def pension_age_households(draw): + lha_rate = draw(money(20_000)) + return dict( + ages=draw(st.lists(st.integers(67, 95), min_size=1, max_size=2)), + # Often above the Pension Credit minimum guarantee, so the taper bites. + state_pension=draw(st.one_of(above(14_000, 10_000), money(20_000))), + private_pension=draw(st.one_of(st.just(0.0), money(30_000))), + tenure=draw(st.sampled_from(RENTED_TENURES)), + rent=draw(st.one_of(above(lha_rate, 15_000), money(30_000))), + lha_rate=lha_rate, + ) + + +@settings(PROPERTY_SETTINGS, max_examples=15) +@given(st.lists(pension_age_households(), min_size=1, max_size=20)) +def test_end_to_end_pension_age_housing_benefit(population): + # The applicable income and amount are the model's own, so this holds + # whatever the earnings disregard or the Guarantee Credit passport do to + # them. Pensioners are exempt from the benefit cap, and household + # calculations claim every entitled benefit. + people, benunits, households = {}, {}, {} + for i, unit in enumerate(population): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"p{i}_{j}" + people[name] = { + "age": {YEAR: age}, + "state_pension_reported": {YEAR: unit["state_pension"]}, + "private_pension_income": {YEAR: unit["private_pension"]}, + } + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + } + households[f"h{i}"] = { + "members": names, + "rent": {YEAR: unit["rent"]}, + "tenure_type": {YEAR: unit["tenure"]}, + } + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + def get(variable): + return np.asarray(sim.calculate(variable, YEAR), dtype=float) + + rate = sim.tax_benefit_system.parameters( + YEAR + ).gov.dwp.housing_benefit.means_test.withdrawal_rate + rent = get("benunit_rent") + lha = get("LHA_eligible").astype(bool) + eligible_rent = np.where(lha, get("LHA_cap"), rent) + taper = rate * np.maximum( + 0, + get("housing_benefit_applicable_income") + - get("housing_benefit_applicable_amount"), + ) + expected = np.maximum( + 0, eligible_rent - get("housing_benefit_non_dep_deductions") - taper + ) + hb = get("housing_benefit") + eligible = get("housing_benefit_eligible").astype(bool) + assert np.allclose(hb[eligible], expected[eligible], atol=TOLERANCE), population + assert np.all(hb[~eligible] == 0) + lha_limit = np.minimum(rent, get("BRMA_LHA_rate")) + assert np.all(hb[lha] <= lha_limit[lha] + TOLERANCE), population diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py index f44c35d135..f3e1c0355b 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py @@ -3,21 +3,59 @@ class housing_benefit_entitlement(Variable): label = "Housing Benefit entitlement" + documentation = ( + "The appropriate maximum Housing Benefit (the eligible rent less " + "non-dependant deductions), less 65% of the excess of applicable " + "income over the applicable amount. Where the Local Housing Allowance " + "applies, the eligible rent is the maximum rent (LHA), which is the " + "lower of the LHA rate and the rent, so the cap applies before the " + "taper." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/130", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/70", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/71", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/12D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/50", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/51", + "https://www.legislation.gov.uk/ukpga/1992/7/section/129", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13C", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/14D", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/68", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/69", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13C", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/14D", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/48", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/49", + ) def formula(benunit, period, parameters): + # Where a maximum rent (LHA) is determined, it is the eligible rent + # (SI 2006/213 and SI 2006/214 reg 12D(2)(a); NI SR 2006/405 and + # SR 2006/406 reg 13C(2)(a)). LHA_cap is the LHA rate or, where the + # rent is lower, the rent (the cap rent: reg 13D(5); NI reg 14D(5)). rent = benunit("benunit_rent", period) + lha_eligible = benunit("LHA_eligible", period) + eligible_rent = where(lha_eligible, benunit("LHA_cap", period), rent) + # The appropriate maximum Housing Benefit is the eligible rent less + # non-dependant deductions (SI 2006/213 reg 70, SI 2006/214 reg 50; + # NI regs 68 and 48). + non_dep_deductions = benunit("housing_benefit_non_dep_deductions", period) + maximum_housing_benefit = eligible_rent - non_dep_deductions + # 65% of the excess of income over the applicable amount is deducted + # from the maximum (SSCBA 1992 s.130(3)(b), SI 2006/213 reg 71, + # SI 2006/214 reg 51; NI: 1992 Act s.129(3)(b), regs 69(b) and 49(b)). applicable_amount = benunit("housing_benefit_applicable_amount", period) income = benunit("housing_benefit_applicable_income", period) withdrawal_rate = parameters( period ).gov.dwp.housing_benefit.means_test.withdrawal_rate - reduced_income = max_(0, income - applicable_amount) - final_amount = max_(0, rent - reduced_income * withdrawal_rate) - capped_final_amount = min_(final_amount, benunit("LHA_cap", period)) - lha_eligible = benunit("LHA_eligible", period.this_year) - amount = where(lha_eligible, capped_final_amount, final_amount) - return max_(0, amount - benunit("housing_benefit_non_dep_deductions", period)) + taper = max_(0, income - applicable_amount) * withdrawal_rate + return max_(0, maximum_housing_benefit - taper) From 67d36b15316e47988f64495a9bd22d2e2be8cb43 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 09:56:02 -0400 Subject: [PATCH 2/5] Test the LHA order end to end with non-dependants and cite the NI rules 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 --- changelog.d/hb-lha-cap-before-taper.fixed.md | 2 +- .../programs/gov/dwp/housing-benefit.ipynb | 4 +- ...st_housing_benefit_lha_order_properties.py | 85 +++++++++++++------ .../housing_benefit_entitlement.py | 3 +- 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/changelog.d/hb-lha-cap-before-taper.fixed.md b/changelog.d/hb-lha-cap-before-taper.fixed.md index 88d5d68a50..16e17d3451 100644 --- a/changelog.d/hb-lha-cap-before-taper.fixed.md +++ b/changelog.d/hb-lha-cap-before-taper.fixed.md @@ -1 +1 @@ -Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper, as SI 2006/213 regs 12D(2)(a), 70 and 71 (and SI 2006/214 regs 12D, 50 and 51) require. The taper is now deducted from the LHA rather than from the rent, so private tenants whose rent exceeds the LHA and whose income exceeds the applicable amount no longer receive the full LHA; previously the cap applied after the taper, which removed the taper whenever the rent exceeded the LHA by more than the taper. +Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper, as SI 2006/213 regs 12D(2)(a), 70 and 71 (and SI 2006/214 regs 12D, 50 and 51; in Northern Ireland SR 2006/405 regs 13C, 68 and 69 and SR 2006/406 regs 13C, 48 and 49) require. The taper is now deducted from the LHA rather than from the rent. Previously the cap applied after the taper, which cut the taper by the excess of the rent over the LHA and removed it entirely when that excess was at least the taper, so private tenants with rent above the LHA and income above the applicable amount received too much. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index b2129d879b..3fa1f88ec0 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -38,9 +38,9 @@ "\n", "The award is computed in four stages:\n", "\n", - "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate or, where the rent is lower, the rent (`LHA_cap`; [SI 2006/213 regs 12D(2)(a) and 13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D)).\n", + "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate or, where the rent is lower, the rent (`LHA_cap`; SI 2006/213 regs [12D(2)(a)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D) and [13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/13D)).\n", "2. **Applicable amount** (`housing_benefit_applicable_amount`): a personal allowance derived from the benunit's composition (single, couple, lone parent) and age, plus any disability/carer premiums. This is the \"minimum income\" the household needs before HB tapers begin.\n", - "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51.\n", + "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51, and the Northern Ireland regulations at SR 2006/405 regs 13C, 14D, 68 and 69 and SR 2006/406 regs 13C, 14D, 48 and 49.\n", "4. **Benefit cap** (`housing_benefit`): the post-means-test award is finally reduced by `benefit_cap_reduction` if the household's total benefit income exceeds the cap and no cap exemption applies.\n", "\n", "The take-up gate (`would_claim_housing_benefit`) is applied inside `housing_benefit_pre_benefit_cap`." diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py index 05cc05a394..9d0a30ee17 100644 --- a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -17,7 +17,9 @@ 1. Bounds: 0 <= HB <= eligible rent <= rent, and for LHA tenants HB <= LHA_cap = min(rent, LHA rate). -2. Closed form: HB equals the formula above. +2. Closed form: HB equals the formula above. This pins the formula to the + law as read here; the hand-computed YAML cases, the bounds and the + differential are the independent checks. 3. Monotonicity: HB is non-increasing in applicable income and in non-dependant deductions, and non-decreasing in rent, the LHA rate and the applicable amount. @@ -29,8 +31,9 @@ where there is no taper, and where the old award was 0. Otherwise the new award is lower, and old - new = min(rent - LHA_cap, taper, old) exactly. This difference is intended. -6. End to end: for pension-age renters, housing_benefit satisfies the closed - form in the model's own applicable income and amount. +6. End to end: for pension-age renters, some with a working-age + non-dependant, housing_benefit satisfies the closed form in the model's + own applicable income, applicable amount and non-dependant deductions. """ import numpy as np @@ -233,16 +236,14 @@ def pension_age_households(draw): tenure=draw(st.sampled_from(RENTED_TENURES)), rent=draw(st.one_of(above(lha_rate, 15_000), money(30_000))), lha_rate=lha_rate, + # A working-age adult in their own benefit unit, whose presence + # brings a non-dependant deduction. + non_dependant_earnings=draw(st.one_of(st.none(), money(40_000))), ) -@settings(PROPERTY_SETTINGS, max_examples=15) -@given(st.lists(pension_age_households(), min_size=1, max_size=20)) -def test_end_to_end_pension_age_housing_benefit(population): - # The applicable income and amount are the model's own, so this holds - # whatever the earnings disregard or the Guarantee Credit passport do to - # them. Pensioners are exempt from the benefit cap, and household - # calculations claim every entitled benefit. +def end_to_end(population): + """Housing Benefit and its inputs, all computed by the model.""" people, benunits, households = {}, {}, {} for i, unit in enumerate(population): names = [] @@ -258,35 +259,69 @@ def test_end_to_end_pension_age_housing_benefit(population): "members": names, "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, } + household_members = list(names) + if unit["non_dependant_earnings"] is not None: + # Younger than the pensioners, so never the household head who + # is liable for the rent. + name = f"n{i}" + people[name] = { + "age": {YEAR: 30}, + "employment_income": {YEAR: unit["non_dependant_earnings"]}, + } + benunits[f"n{i}"] = {"members": [name]} + household_members.append(name) households[f"h{i}"] = { - "members": names, + "members": household_members, "rent": {YEAR: unit["rent"]}, "tenure_type": {YEAR: unit["tenure"]}, } sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) - - def get(variable): - return np.asarray(sim.calculate(variable, YEAR), dtype=float) - + values = { + variable: np.asarray(sim.calculate(variable, YEAR), dtype=float) + for variable in [ + "housing_benefit", + "housing_benefit_eligible", + "benunit_rent", + "BRMA_LHA_rate", + "LHA_cap", + "LHA_eligible", + "housing_benefit_applicable_amount", + "housing_benefit_applicable_income", + "housing_benefit_non_dep_deductions", + ] + } rate = sim.tax_benefit_system.parameters( YEAR ).gov.dwp.housing_benefit.means_test.withdrawal_rate - rent = get("benunit_rent") - lha = get("LHA_eligible").astype(bool) - eligible_rent = np.where(lha, get("LHA_cap"), rent) - taper = rate * np.maximum( + values["lha"] = values["LHA_eligible"].astype(bool) + values["taper"] = rate * np.maximum( 0, - get("housing_benefit_applicable_income") - - get("housing_benefit_applicable_amount"), + values["housing_benefit_applicable_income"] + - values["housing_benefit_applicable_amount"], ) + return values + + +@settings(PROPERTY_SETTINGS, max_examples=15) +@given(st.lists(pension_age_households(), min_size=1, max_size=20)) +def test_end_to_end_pension_age_housing_benefit(population): + # The applicable income and amount and the non-dependant deductions are + # the model's own, so this holds whatever the earnings disregard or the + # Guarantee Credit passport do to them. Pensioners are exempt from the + # benefit cap, and household calculations claim every entitled benefit. + values = end_to_end(population) + rent = values["benunit_rent"] + lha = values["lha"] + eligible_rent = np.where(lha, values["LHA_cap"], rent) expected = np.maximum( - 0, eligible_rent - get("housing_benefit_non_dep_deductions") - taper + 0, + eligible_rent - values["housing_benefit_non_dep_deductions"] - values["taper"], ) - hb = get("housing_benefit") - eligible = get("housing_benefit_eligible").astype(bool) + hb = values["housing_benefit"] + eligible = values["housing_benefit_eligible"].astype(bool) assert np.allclose(hb[eligible], expected[eligible], atol=TOLERANCE), population assert np.all(hb[~eligible] == 0) - lha_limit = np.minimum(rent, get("BRMA_LHA_rate")) + lha_limit = np.minimum(rent, values["BRMA_LHA_rate"]) assert np.all(hb[lha] <= lha_limit[lha] + TOLERANCE), population diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py index f3e1c0355b..b8771d4d6b 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py @@ -51,7 +51,8 @@ def formula(benunit, period, parameters): maximum_housing_benefit = eligible_rent - non_dep_deductions # 65% of the excess of income over the applicable amount is deducted # from the maximum (SSCBA 1992 s.130(3)(b), SI 2006/213 reg 71, - # SI 2006/214 reg 51; NI: 1992 Act s.129(3)(b), regs 69(b) and 49(b)). + # SI 2006/214 reg 51; NI: SSCB(NI)A 1992 s.129(3)(b), regs 69(b) and + # 49(b)). applicable_amount = benunit("housing_benefit_applicable_amount", period) income = benunit("housing_benefit_applicable_income", period) withdrawal_rate = parameters( From ee18b856102dade8ec950bb184fe6695056d2a47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 10:23:19 -0400 Subject: [PATCH 3/5] Make the LHA-order property tests reach the cases where the orders differ 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 --- ...st_housing_benefit_lha_order_properties.py | 147 +++++++++++++++--- 1 file changed, 124 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py index 9d0a30ee17..a61eda1dfd 100644 --- a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -34,10 +34,14 @@ 6. End to end: for pension-age renters, some with a working-age non-dependant, housing_benefit satisfies the closed form in the model's own applicable income, applicable amount and non-dependant deductions. + +Each test also runs an explicit example where the two orders differ (one +unit for each term of the difference in invariant 5), so every test fails +under the old order whatever Hypothesis draws. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -46,7 +50,7 @@ # Engine values are float32; 0.05 covers rounding on amounts up to 100,000. TOLERANCE = 0.05 PROPERTY_SETTINGS = settings( - max_examples=25, + max_examples=15, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], @@ -56,9 +60,13 @@ NON_DECREASING = ["rent", "lha_rate", "applicable_amount"] -def money(high): +def between(low, high): # Whole pence, so the arithmetic is exact to the penny. - return st.integers(0, high * 100).map(lambda pence: pence / 100) + return st.integers(low * 100, high * 100).map(lambda pence: pence / 100) + + +def money(high): + return between(0, high) def above(floor, high): @@ -69,25 +77,86 @@ def above(floor, high): @st.composite def units(draw): - lha_rate = draw(money(30_000)) - applicable_amount = draw(money(25_000)) + # Hypothesis favours small draws. An LHA of a few pounds would leave + # every tapered award at 0, where the two orders agree, so the LHA and + # the applicable amount start at realistic levels. + lha_rate = draw(between(2_000, 20_000)) + applicable_amount = draw(between(5_000, 25_000)) return dict( tenure=draw(st.sampled_from(RENTED_TENURES)), lha_rate=lha_rate, - # Many draws put the rent above the LHA and the income above the - # applicable amount, where capping before or after the taper differs. + # Many draws put the rent above the LHA and the income a little above + # the applicable amount, where capping before or after the taper + # differs. rent=draw(st.one_of(money(40_000), above(lha_rate, 20_000))), applicable_amount=applicable_amount, income=draw( - st.one_of(st.just(0.0), money(60_000), above(applicable_amount, 15_000)) + st.one_of(st.just(0.0), money(60_000), above(applicable_amount, 10_000)) ), - non_dep_deductions=draw(st.one_of(st.just(0.0), money(10_000))), + non_dep_deductions=draw(st.one_of(st.just(0.0), money(5_000))), ) -POPULATION = st.lists(units(), min_size=1, max_size=40) +POPULATION = st.lists(units(), min_size=10, max_size=40) BUMP = money(10_000).filter(lambda bump: bump > 0) +# One unit for each term of old - new = min(rent - LHA_cap, taper, old), +# worked by hand, and a council tenant for whom the orders agree. Taper is +# 0.65 * (income - applicable amount). +DIFFERENCE_EXAMPLES = [ + # The taper binds: taper 0.65 * 1,636.80 = 1,063.92; new 9,467.64 - + # 1,063.92 = 8,403.72; old min(30,000 - 1,063.92, 9,467.64) = 9,467.64. + dict( + tenure="RENT_PRIVATELY", + lha_rate=9_467.64, + rent=30_000.0, + applicable_amount=13_312.0, + income=14_948.80, + non_dep_deductions=0.0, + ), + # The rent excess binds: taper 0.65 * 8,000 = 5,200; new 9,467.64 - + # 5,200 = 4,267.64; old min(12,000 - 5,200, 9,467.64) = 6,800; the + # difference is 12,000 - 9,467.64 = 2,532.36. + dict( + tenure="RENT_PRIVATELY", + lha_rate=9_467.64, + rent=12_000.0, + applicable_amount=13_312.0, + income=21_312.0, + non_dep_deductions=0.0, + ), + # The old award binds: taper 0.65 * 4,192.20 = 2,724.93; new max(0, + # 8,000 - 6,000 - 2,724.93) = 0; old min(9,000 - 2,724.93, 8,000) - + # 6,000 = 275.07. + dict( + tenure="RENT_PRIVATELY", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=6_000.0, + ), + # rulespec-uk pipeline case 4: new 8,000 - 3,346.20 - 2,724.93 = + # 1,928.87; old 6,275.07 - 3,346.20 = 2,928.87. + dict( + tenure="RENT_PRIVATELY", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=3_346.20, + ), + # The same numbers for a council tenant: 2,928.87 under both orders. + dict( + tenure="RENT_FROM_COUNCIL", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=3_346.20, + ), +] + def calculate(population): """Housing Benefit inputs and entitlement for independent benefit units. @@ -152,6 +221,7 @@ def old_entitlement(values): @PROPERTY_SETTINGS @given(POPULATION) +@example(DIFFERENCE_EXAMPLES) def test_bounds_closed_form_and_differential(population): values = calculate(population) hb = values["housing_benefit_entitlement"] @@ -192,8 +262,9 @@ def test_bounds_closed_form_and_differential(population): assert np.allclose(old - hb, difference, atol=TOLERANCE), population -@settings(PROPERTY_SETTINGS, max_examples=15) +@settings(PROPERTY_SETTINGS, max_examples=10) @given(POPULATION, st.lists(BUMP, min_size=5, max_size=5)) +@example(DIFFERENCE_EXAMPLES, [1_000.0] * 5) def test_monotonicity(population, bumps): fields = NON_INCREASING + NON_DECREASING variants = [population] + [ @@ -212,27 +283,27 @@ def test_monotonicity(population, bumps): # 4. For LHA tenants already at or above the LHA, more rent changes # nothing: the eligible rent is the LHA. - above = np.array( + at_or_above_lha = np.array( [ unit["tenure"] == "RENT_PRIVATELY" and unit["rent"] >= unit["lha_rate"] for unit in population ] ) after_rent = hb[1 + fields.index("rent")] - assert np.allclose(after_rent[above], before[above], atol=TOLERANCE), ( - population, - bumps, - ) + assert np.allclose( + after_rent[at_or_above_lha], before[at_or_above_lha], atol=TOLERANCE + ), (population, bumps) @st.composite def pension_age_households(draw): - lha_rate = draw(money(20_000)) + lha_rate = draw(between(4_000, 15_000)) return dict( ages=draw(st.lists(st.integers(67, 95), min_size=1, max_size=2)), - # Often above the Pension Credit minimum guarantee, so the taper bites. - state_pension=draw(st.one_of(above(14_000, 10_000), money(20_000))), - private_pension=draw(st.one_of(st.just(0.0), money(30_000))), + # Often a little above the Pension Credit minimum guarantee, so the + # taper bites without removing the award. + state_pension=draw(st.one_of(above(14_000, 6_000), money(20_000))), + private_pension=draw(st.one_of(st.just(0.0), money(5_000))), tenure=draw(st.sampled_from(RENTED_TENURES)), rent=draw(st.one_of(above(lha_rate, 15_000), money(30_000))), lha_rate=lha_rate, @@ -242,6 +313,35 @@ def pension_age_households(draw): ) +# The single pensioner from the issue (State Pension 16,000, above the +# minimum guarantee; rent 30,000 against an LHA of 9,467.64), alone and with +# a working-age non-dependant, and a pension-age couple in the same flat. +# Each has rent above the LHA and income above the applicable amount, where +# the two orders differ. +END_TO_END_EXAMPLE = [ + dict( + ages=[70], + state_pension=16_000.0, + private_pension=0.0, + tenure="RENT_PRIVATELY", + rent=30_000.0, + lha_rate=9_467.64, + non_dependant_earnings=non_dependant_earnings, + ) + for non_dependant_earnings in [None, 20_000.0] +] + [ + dict( + ages=[70, 68], + state_pension=12_000.0, + private_pension=0.0, + tenure="RENT_PRIVATELY", + rent=30_000.0, + lha_rate=9_467.64, + non_dependant_earnings=None, + ) +] + + def end_to_end(population): """Housing Benefit and its inputs, all computed by the model.""" people, benunits, households = {}, {}, {} @@ -304,8 +404,9 @@ def end_to_end(population): return values -@settings(PROPERTY_SETTINGS, max_examples=15) -@given(st.lists(pension_age_households(), min_size=1, max_size=20)) +@settings(PROPERTY_SETTINGS, max_examples=10) +@given(st.lists(pension_age_households(), min_size=5, max_size=20)) +@example(END_TO_END_EXAMPLE) def test_end_to_end_pension_age_housing_benefit(population): # The applicable income and amount and the non-dependant deductions are # the model's own, so this holds whatever the earnings disregard or the From 02aa1eb7712b08529a73f8f1daeed8f0262ae5fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:47:09 -0400 Subject: [PATCH 4/5] Trim the LHA-order changelog entry to one sentence 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 --- changelog.d/hb-lha-cap-before-taper.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/hb-lha-cap-before-taper.fixed.md b/changelog.d/hb-lha-cap-before-taper.fixed.md index 16e17d3451..85a7208322 100644 --- a/changelog.d/hb-lha-cap-before-taper.fixed.md +++ b/changelog.d/hb-lha-cap-before-taper.fixed.md @@ -1 +1 @@ -Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper, as SI 2006/213 regs 12D(2)(a), 70 and 71 (and SI 2006/214 regs 12D, 50 and 51; in Northern Ireland SR 2006/405 regs 13C, 68 and 69 and SR 2006/406 regs 13C, 48 and 49) require. The taper is now deducted from the LHA rather than from the rent. Previously the cap applied after the taper, which cut the taper by the excess of the rent over the LHA and removed it entirely when that excess was at least the taper, so private tenants with rent above the LHA and income above the applicable amount received too much. +Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper and non-dependant deductions, as SI 2006/213 regs 12D(2)(a), 70 and 71 (SI 2006/214 regs 12D, 50 and 51 at pension age) require, rather than capping the tapered rent. From 066cb2dd6a3781d2af142ab54c6b7dee6c9a4aa7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 10:02:59 -0400 Subject: [PATCH 5/5] Label LHA_cap as the maximum rent (LHA) and explain the test inputs 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 --- .../entitlement/housing_benefit_lha_order.yaml | 4 +++- policyengine_uk/variables/gov/dwp/LHA_cap.py | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml index 1dfe23a501..4714f22593 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml @@ -45,7 +45,9 @@ # test pins the order of the cap and the taper, not the LHA table. BRMA_LHA_rate: 9_467.64 # Inputs, so the earnings disregard and the pension-age allowance - # cannot move the taper. + # cannot move the taper. The applicable income input overrides the + # income computed from state_pension_reported, which only keeps + # guarantee_credit at 0 below. housing_benefit_applicable_amount: 13_312 housing_benefit_applicable_income: 14_948.80 households: diff --git a/policyengine_uk/variables/gov/dwp/LHA_cap.py b/policyengine_uk/variables/gov/dwp/LHA_cap.py index d7139422ce..090fcd23fb 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/LHA_cap.py @@ -9,8 +9,13 @@ class LHA_cap(Variable): value_type = float entity = BenUnit - label = "Applicable amount for LHA" - documentation = "Applicable amount for Local Housing Allowance" + label = "Maximum rent (LHA)" + documentation = ( + "The Housing Benefit maximum rent (LHA): the Local Housing Allowance " + "rate for the benefit unit, or its rent where that is lower (SI " + "2006/213 and 2006/214 reg 13D(5): where the LHA exceeds the cap " + "rent, the maximum rent (LHA) is the cap rent)." + ) definition_period = YEAR unit = GBP