From e70d95531e86307255ef0c2fd88d08b6d120f778 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:52:21 +0400 Subject: [PATCH 1/7] Add Housing Benefit special standard earnings disregard --- changelog.d/hb-special-disregard.added.md | 1 + .../means_test/income_disregard/special.yaml | 16 +++ .../housing_benefit_special_disregard.yaml | 104 ++++++++++++++++++ ...ing_benefit_applicable_income_disregard.py | 14 +-- ...sing_benefit_special_earnings_disregard.py | 37 +++++++ ...fit_special_earnings_disregard_eligible.py | 61 ++++++++++ 6 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 changelog.d/hb-special-disregard.added.md create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/income_disregard/special.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py diff --git a/changelog.d/hb-special-disregard.added.md b/changelog.d/hb-special-disregard.added.md new file mode 100644 index 0000000000..8a9d2c8319 --- /dev/null +++ b/changelog.d/hb-special-disregard.added.md @@ -0,0 +1 @@ +Added the Housing Benefit special standard earnings disregard with regime-specific qualifying routes, explicit residual pre-assessment and no stacking with ordinary amounts. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/income_disregard/special.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/income_disregard/special.yaml new file mode 100644 index 0000000000..e1f45b0c7d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/income_disregard/special.yaml @@ -0,0 +1,16 @@ +description: Weekly Housing Benefit special standard earnings disregard, replacing the ordinary single or couple amount. +values: + 2015-04-01: 20 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit special earnings disregard + reference: + - title: GB working-age earnings disregards, paragraphs 3 and 5 to 9 + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/4 + - title: GB pension-age earnings disregards, paragraphs 1 to 7 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/4 + - title: NI working-age earnings disregards + href: https://www.legislation.gov.uk/nisr/2006/405/schedule/5 + - title: NI pension-age earnings disregards + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/5 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml new file mode 100644 index 0000000000..af71a63ef8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml @@ -0,0 +1,104 @@ +# Cases: working disability/severe; working carer-only; pension carer; +# pension generic disability premium; pension/working DLA; non-dependant DLA; +# pension/working blindness. +- name: ENGLAND statutory regime distinguishes automatic qualifying routes + period: 2024 + input: + country: [ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND] + housing_benefit_pension_age_regulations_apply: [false, false, false, true, true, true, false, true, true, false] + disability_premium: [100, 0, 0, 0, 100, 0, 0, 0, 0, 0] + severe_disability_premium: [0, 100, 0, 0, 0, 0, 0, 0, 0, 0] + carer_premium: [0, 0, 100, 100, 0, 0, 0, 0, 0, 0] + dla_sc: [0, 0, 0, 0, 0, 100, 100, 100, 0, 0] + dla_m: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + pip_dl: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + pip_m: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + attendance_allowance: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + armed_forces_independence_payment: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + is_blind: [false, false, false, false, false, false, false, false, true, true] + is_claimant_or_partner: [true, true, true, true, true, true, true, false, true, true] + housing_benefit_net_earnings: [5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000] + is_lone_parent: [false, false, false, false, false, false, false, false, false, false] + is_couple: [false, false, false, false, false, false, false, false, false, false] + output: + housing_benefit_special_earnings_disregard_eligible: [true, true, false, true, false, true, false, false, true, false] + housing_benefit_special_earnings_disregard: [1040, 1040, 260, 1040, 260, 1040, 260, 260, 1040, 260] + +# Cases: working disability/severe; working carer-only; pension carer; +# pension generic disability premium; pension/working DLA; non-dependant DLA; +# pension/working blindness. +- name: NORTHERN_IRELAND statutory regime distinguishes automatic qualifying routes + period: 2024 + input: + country: [NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND] + housing_benefit_pension_age_regulations_apply: [false, false, false, true, true, true, false, true, true, false] + disability_premium: [100, 0, 0, 0, 100, 0, 0, 0, 0, 0] + severe_disability_premium: [0, 100, 0, 0, 0, 0, 0, 0, 0, 0] + carer_premium: [0, 0, 100, 100, 0, 0, 0, 0, 0, 0] + dla_sc: [0, 0, 0, 0, 0, 100, 100, 100, 0, 0] + dla_m: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + pip_dl: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + pip_m: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + attendance_allowance: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + armed_forces_independence_payment: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + is_blind: [false, false, false, false, false, false, false, false, true, true] + is_claimant_or_partner: [true, true, true, true, true, true, true, false, true, true] + housing_benefit_net_earnings: [5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000] + is_lone_parent: [false, false, false, false, false, false, false, false, false, false] + is_couple: [false, false, false, false, false, false, false, false, false, false] + output: + housing_benefit_special_earnings_disregard_eligible: [true, true, false, true, false, true, false, false, true, false] + housing_benefit_special_earnings_disregard: [1040, 1040, 260, 1040, 260, 1040, 260, 260, 1040, 260] + +- name: Standard replacement, lone-parent precedence and earnings cap + period: 2024 + input: + housing_benefit_special_earnings_disregard_eligible: [true, true, true, true, false, false] + housing_benefit_net_earnings: [5000, 5000, 5000, 500, 5000, 5000] + is_couple: [false, true, false, true, false, true] + is_lone_parent: [false, false, true, false, false, false] + housing_benefit_specified_or_temporary_accommodation_disregard: [0, 0, 0, 0, 0, 0] + housing_benefit_applicable_income_childcare_element: [0, 0, 0, 0, 0, 0] + meets_housing_benefit_additional_earnings_disregard_conditions: [false, false, false, false, false, false] + housing_benefit_on_passporting_benefit: [false, false, false, false, false, false] + output: + housing_benefit_applicable_income_disregard: [1040, 1040, 1300, 500, 260, 520] + +- name: Special standard amount participates in additional-disregard threshold + period: 2024 + absolute_error_margin: 0.01 + input: + housing_benefit_special_earnings_disregard_eligible: [true, true, true] + housing_benefit_net_earnings: [1929.19, 1929.20, 5000] + is_couple: [false, true, false] + is_lone_parent: [false, false, false] + housing_benefit_specified_or_temporary_accommodation_disregard: [0, 0, 0] + housing_benefit_applicable_income_childcare_element: [0, 0, 0] + meets_housing_benefit_additional_earnings_disregard_conditions: [true, true, true] + housing_benefit_on_passporting_benefit: [false, false, true] + output: + housing_benefit_applicable_income_disregard: [1040, 1929.20, 5000] + +- name: A zero special-amount reform is not replaced with the ordinary amount + period: 2024 + input: + gov.dwp.housing_benefit.means_test.income_disregard.special: 0 + housing_benefit_special_earnings_disregard_eligible: true + housing_benefit_net_earnings: 5000 + is_lone_parent: false + is_couple: true + output: + housing_benefit_special_earnings_disregard: 0 + +- name: Missing residual qualification preserves ordinary treatment + period: 2024 + input: + disability_premium: 0 + severe_disability_premium: 0 + housing_benefit_pension_age_regulations_apply: false + housing_benefit_net_earnings: 5000 + is_lone_parent: false + is_couple: false + output: + housing_benefit_special_earnings_disregard_eligible: false + housing_benefit_special_earnings_disregard: 260 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.py index a7600bf518..7de5c94a3d 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.py @@ -20,10 +20,11 @@ class housing_benefit_applicable_income_disregard(Variable): "(housing_benefit_on_passporting_benefit) has all earnings " "disregarded, at any age: the working-age Regulations then apply " "(SI 2006/213 reg 5(1)(b)). The " - "£20 disregards for disabled people, carers and some part-time " - "occupations are not modelled, nor is the working-age permitted work " + "£20 standard disregard uses established qualifying routes or an " + "explicit full-amount pre-assessment, without reconstructing partial " + "carer/occupation earnings allocations. The permitted-work " "disregard (Sch 4 para 10A), which replaces paras 3 to 10 but not " - "para 18." + "para 18, is not yet modelled here." ) definition_period = YEAR unit = GBP @@ -42,12 +43,7 @@ def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard net_earnings = benunit("housing_benefit_net_earnings", period) # Working age Sch 4 paras 4, 7 and 10; pension age Sch 4 paras 2 and 7. - weekly_amount = select( - [benunit("is_lone_parent", period), benunit("is_couple", period)], - [p.lone_parent, p.couple], - default=p.single, - ) - standard = min_(weekly_amount * WEEKS_IN_YEAR, net_earnings) + standard = benunit("housing_benefit_special_earnings_disregard", period) # Working age Sch 4 para 18 (NI Sch 5 para 18), from 5 October 2026: # specified or temporary accommodation. It applies alongside paras 3 # to 10A, none of which excludes it, and takes the earnings they diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard.py new file mode 100644 index 0000000000..04d731487a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_special_earnings_disregard(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + label = "Housing Benefit selected standard earnings disregard" + documentation = ( + "Standard disregard selected before the additional/accommodation " + "amounts: lone-parent precedence, otherwise the special amount for " + "a qualifying family, otherwise the ordinary couple/single amount. " + "It is a replacement, not £20 added to £5 or £10. The total standard " + "deduction is capped at the claimant's and partner's net earnings." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/4", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/4", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/5", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/5", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard + weekly = select( + [ + benunit("is_lone_parent", period), + benunit("housing_benefit_special_earnings_disregard_eligible", period), + benunit("is_couple", period), + ], + [p.lone_parent, p.special, p.couple], + default=p.single, + ) + return min_( + weekly * WEEKS_IN_YEAR, benunit("housing_benefit_net_earnings", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py new file mode 100644 index 0000000000..5c15427da6 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py @@ -0,0 +1,61 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_special_earnings_disregard_eligible(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + default_value = False + label = "Qualifies for the full Housing Benefit special earnings disregard" + documentation = ( + "Whether the full special standard amount may legally be deducted " + "from the benefit unit's combined earnings, subject to the earnings " + "cap. Derived for working-age disability/severe-premium routes and " + "pension-age qualifying benefit receipt, blindness or carer premium. " + "Other full-amount routes may be pre-assessed as an input, including " + "the applicable job/partner earnings allocation: mere occupation or " + "carer status is not sufficient. Partial carer/occupation allocations " + "below the full amount are not reconstructed by this Boolean. Existing " + "premium/benefit qualification approximations are inherited. No " + "dataset mapping for residual pre-assessment is verified; a false " + "fallback may omit qualifying ESA phase/history/occupation routes." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/4", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/4", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/5", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/5", + ) + + def formula(benunit, period, parameters): + working = (benunit("disability_premium", period) > 0) | ( + benunit("severe_disability_premium", period) > 0 + ) + person = benunit.members + qualifying_receipt = ( + add( + person, + period, + [ + "attendance_allowance", + "dla_sc", + "dla_m", + "pip_dl", + "pip_m", + "armed_forces_independence_payment", + ], + ) + > 0 + ) + pension = benunit.any( + person("is_claimant_or_partner", period) + & (qualifying_receipt | person("is_blind", period)) + ) | (benunit("carer_premium", period) > 0) + # Working-age carer/occupation routes restrict which person's/job's + # earnings may be disregarded. Do not automatically apply £20 to an + # ordinary earner solely because their non-earning partner cares. + return where( + benunit("housing_benefit_pension_age_regulations_apply", period), + pension, + working, + ) From 6e68f23fafef3a1e0a1718bd5d1e4bc0fa860c72 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:49:18 +0400 Subject: [PATCH 2/7] Update disabled-worker regression for HB special earnings disregard --- ...ng_benefit_applicable_income_disregard.yaml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml index 7cf5e90192..a2dcadd7cd 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_disregard.yaml @@ -430,7 +430,7 @@ # One member must work 30 hours; hours are not added across the couple. housing_benefit_applicable_income_disregard: 520 # £10 × 52 -- name: Disabled single person working 20 hours gets £5 plus the additional disregard (model limitation; the law gives £20 plus it) +- name: Disabled single person working 20 hours gets £20 plus the additional disregard period: 2026 absolute_error_margin: 0.01 input: @@ -448,15 +448,13 @@ # Para 17(2)(b)(iv)(aa): a single claimant with the disability premium # working 16 hours or more meets the condition. meets_housing_benefit_additional_earnings_disregard_conditions: true - # KNOWN LIMITATION: the law gives £1,929.20. Intended model limitation. - # In law the same disability premium brings in - # the £20 disregard in place of £5: Sch 4 para 3(2), "This paragraph - # applies where the claimant's applicable amount includes an amount by - # way of the disability premium". The law gives (£20 + £17.10) × 52 = - # £1,929.20 (£8,000 ≥ £1,040 + £889.20). The £20 disregards are out of - # scope for this change and tracked as a follow-up; until then the model - # gives (£5 + £17.10) × 52. - housing_benefit_applicable_income_disregard: 1_149.20 + # The disability premium gives the £20 standard disregard under Sch 4 + # para 3(2), replacing £5. Net earnings cover that amount and the + # additional disregard: £8,000 ≥ £1,040 + £889.20. + # (£20 + £17.10) × 52 = £1,929.20. + # https://www.legislation.gov.uk/uksi/2006/213/schedule/4/paragraph/3 + # https://www.legislation.gov.uk/uksi/2006/213/schedule/4/paragraph/17 + housing_benefit_applicable_income_disregard: 1_929.20 - name: Working-age claimant on Income Support has all earnings disregarded period: 2026 From de8eb6a78b446569b41d856e0d03e1ab20afbef8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 18:15:14 -0400 Subject: [PATCH 3/7] =?UTF-8?q?Account=20for=20the=20special=20=C2=A320=20?= =?UTF-8?q?disregard=20in=20three=20HB=20property=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three property tests that predate this PR assumed the £5/£10/£25 standard disregards only. The PR is a draft, so CI had not run them: - test_pension_age_and_working_age_schedules_agree compared the two schedules for every family. The special £20 routes differ by design (working age: a disability or severe disability premium; pension age: receipt of a listed benefit, blindness or a carer premium), so the test now compares the disregards only where the model's eligibility flag agrees, and asserts that the flag agrees for every family with no disabled adult. - test_differential_against_the_old_flat_formula now expects min(£20 x 52, net earnings) for a qualifying single person or couple; a lone parent keeps £25. - The para 18 differential's statutory reference now takes the para 3 special £20 from the model's eligibility flag, as it already takes the para 17(2) work condition. test_housing_benefit_earnings_disregard_properties.py tests the flag itself. Co-Authored-By: Claude Opus 5.5 --- ...g_benefit_earnings_disregard_properties.py | 33 +++++++++++++++---- ...fied_accommodation_disregard_properties.py | 31 ++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py index 06c0663331..9aee1a7a90 100644 --- a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py @@ -2,7 +2,8 @@ SI 2006/213 Sch 4 (working age) and SI 2006/214 Sch 4 (pension age) disregard £25 a week of a lone parent's net earnings, £10 of a couple's and £5 of anyone -else's, plus the £17.10 additional earnings disregard where a work condition +else's, or £20 in place of the £5 or £10 for a family that qualifies for the +special disregard (housing_benefit_special_earnings_disregard_eligible), plus the £17.10 additional earnings disregard where a work condition is met and net earnings at least equal the other disregards, the deductible childcare charges and £17.10. A claimant who, or whose partner, is on Universal Credit, Income Support, income-based JSA or income-related ESA has @@ -28,11 +29,19 @@ over, where nothing age-dependent (para 12, the age-25 test, National Insurance above State Pension age) can differ between the two groups, so it pins the current behaviour rather than independently testing the two - schedules. + schedules. Intended difference: the £20 special disregard has different + conditions at each age. At working age it follows the disability premium, + which the model grants on the disability flag the draws set; at pension age + it needs receipt of a listed benefit, blindness or the carer premium (SI + 2006/214 Sch 4 paras 4 and 5), none of which the draws supply. The + comparison therefore covers families whose special eligibility is the same + at both ages, which includes every family without a disabled adult. 7. Differential against the old flat formula (£5/£10/£25 by family type, plus £37.10 for summed hours over 30, or 16 for lone parents), read without CPI uprating. They agree for families whose net earnings cover the flat - amount and to whom neither formula gives the additional disregard. They + amount and to whom neither formula gives the additional disregard or the + special disregard; a couple or single person who qualifies for the special + disregard, and whose net earnings cover £20, gets £20 instead. They must differ where net earnings fall below the flat amount (the new disregard is the net earnings) and for families without earnings (the new disregard is zero). @@ -159,6 +168,7 @@ def situation(units, earnings_bump=0.0, income_support=0.0, age_map=None): "housing_benefit_applicable_income", "housing_benefit_applicable_income_childcare_element", "meets_housing_benefit_additional_earnings_disregard_conditions", + "housing_benefit_special_earnings_disregard_eligible", ] @@ -246,8 +256,13 @@ def test_pension_age_and_working_age_schedules_agree(units): # Map 25-60 onto 67-90, keeping everyone at or over 25. pension_age = calculate(units, age_map=lambda age: 67 + (age - 25) * 23 // 35) key = "housing_benefit_applicable_income_disregard" + special = "housing_benefit_special_earnings_disregard_eligible" for i, unit in enumerate(units): - assert abs(working_age[key][i] - pension_age[key][i]) < 0.01, unit + if not any(adult["disabled"] for adult in unit["adults"]): + # Nothing else in the draws can make the two routes differ. + assert working_age[special][i] == pension_age[special][i], unit + if working_age[special][i] == pension_age[special][i]: + assert abs(working_age[key][i] - pension_age[key][i]) < 0.01, unit @PROPERTY_SETTINGS @@ -259,6 +274,7 @@ def test_differential_against_the_old_flat_formula(units): additional = values[ "meets_housing_benefit_additional_earnings_disregard_conditions" ].astype(bool) + special = values["housing_benefit_special_earnings_disregard_eligible"].astype(bool) for i, unit in enumerate(units): old = old_flat_formula(unit) flat = OLD_FLAT[unit["shape"]] * WEEKS @@ -272,8 +288,13 @@ def test_differential_against_the_old_flat_formula(units): assert abs(disregard[i] - net[i]) < 0.01, unit assert disregard[i] < old, unit elif not old_worker and not additional[i]: - # Agree: the flat amount, without CPI uprating. - assert abs(disregard[i] - old) < 0.01, unit + if special[i] and unit["shape"] != "lone_parent": + # Must differ: £20 replaces the flat £5 or £10 (lone parents + # keep £25, which takes precedence). + assert abs(disregard[i] - min(20 * WEEKS, net[i])) < 0.01, unit + else: + # Agree: the flat amount, without CPI uprating. + assert abs(disregard[i] - old) < 0.01, unit @PROPERTY_SETTINGS diff --git a/policyengine_uk/tests/test_housing_benefit_specified_accommodation_disregard_properties.py b/policyengine_uk/tests/test_housing_benefit_specified_accommodation_disregard_properties.py index 0306c17183..85efbaf541 100644 --- a/policyengine_uk/tests/test_housing_benefit_specified_accommodation_disregard_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_specified_accommodation_disregard_properties.py @@ -9,8 +9,8 @@ claimant or lone parent under 25, £77.73 at 25 or over; for a couple £97.33 where both are under 18, £61.53 where one is 18 or over but both are under 25, and £119.70 where one is 25 or over. A couple has one amount. It is added -to the £5/£10/£25 disregards (paras 10, 7 and 4), and para 17(3)(a) counts it -in the £17.10 additional disregard's earnings test. The pension-age +to the £5/£10/£20/£25 disregards (paras 10, 7, 3 and 4), and para 17(3)(a) +counts it in the £17.10 additional disregard's earnings test. The pension-age Regulations (SI 2006/214; NI SR 2006/406) have no such disregard. Invariants, for 2027 (the first fiscal year the model reads with para 18 in @@ -32,9 +32,12 @@ 5. Differential: the model agrees with a reference written from the statutory text. The reference is independent for the para 18 bands and gates (amounts hard-coded from the instrument, ages, family type, earner - and pension-age status from the generated inputs) and for how paras 4, 7, - 10, 17 and 18 combine. It takes net earnings, deductible childcare and - the para 17(2) work condition from the model, which other tests cover. + and pension-age status from the generated inputs) and for how paras 3, 4, + 7, 10, 17 and 18 combine. It takes net earnings, deductible childcare, + the para 17(2) work condition and whether the para 3 special £20 applies + from the model, which other tests cover + (test_housing_benefit_earnings_disregard_properties.py tests the special + £20). Explicit examples pin the age boundaries (couples aged 17/17, 17/18 and 24/25; single claimants and lone parents aged 24 and 25). 6. Scope: families whose claimant and partner are all over State Pension @@ -80,6 +83,8 @@ } # Paras 4, 7 and 10, and para 17. BASE_WEEKLY = {"single": 5, "lone_parent": 25, "couple": 10, "couple_with_children": 10} +# Para 3: replaces the single or couple amount; a lone parent keeps £25. +SPECIAL_WEEKLY = 20 ADDITIONAL_WEEKLY = 17.10 MAX_ANNUAL = (25 + COUPLE_25_PLUS + ADDITIONAL_WEEKLY) * WEEKS @@ -198,6 +203,7 @@ def situation(units, earnings_bump=0.0, income_support=0.0, accommodation=None): "housing_benefit_applicable_income", "housing_benefit_applicable_income_childcare_element", "meets_housing_benefit_additional_earnings_disregard_conditions", + "housing_benefit_special_earnings_disregard_eligible", "housing_benefit_pension_age_regulations_apply", ] @@ -229,10 +235,14 @@ def reference_para_18_weekly(unit): return SINGLE_25_PLUS if eldest >= 25 else SINGLE_UNDER_25 -def reference_disregard(unit, net, childcare, work_condition): - """Paras 4, 7, 10, 17 and 18 combined, from net earnings, the deductible - childcare charges and whether a para 17(2) work condition is met.""" - standard = min(BASE_WEEKLY[unit["shape"]] * WEEKS, net) +def reference_disregard(unit, net, childcare, work_condition, special=False): + """Paras 3, 4, 7, 10, 17 and 18 combined, from net earnings, the deductible + childcare charges, whether a para 17(2) work condition is met and whether + the para 3 special £20 applies.""" + weekly = BASE_WEEKLY[unit["shape"]] + if special and unit["shape"] != "lone_parent": + weekly = SPECIAL_WEEKLY + standard = min(weekly * WEEKS, net) accommodation = min(reference_para_18_weekly(unit) * WEEKS, net - standard) additional = ADDITIONAL_WEEKLY * WEEKS covers = round(net, 2) >= round( @@ -368,6 +378,9 @@ def test_differential_against_the_statutory_reference(units): "meets_housing_benefit_additional_earnings_disregard_conditions" ][i] ), + special=bool( + values["housing_benefit_special_earnings_disregard_eligible"][i] + ), ) total = values["housing_benefit_applicable_income_disregard"][i] assert abs(total - expected) < 0.02, (unit, total, expected) From 375d7727a97d6baddaa9fe8d71ab78b6e067e781 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 01:24:45 -0400 Subject: [PATCH 4/7] Complete pension-age HB disability receipt routes Include SDA and disability-related WTC, retain claimant/partner boundaries, and pin the selected standard directly alongside GB/NI receipt regressions. Co-Authored-By: Claude Opus 5.5 --- .../housing_benefit_special_disregard.yaml | 271 ++++++++++++++++++ ...fit_special_earnings_disregard_eligible.py | 53 +++- 2 files changed, 320 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml index af71a63ef8..342e19feec 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_special_disregard.yaml @@ -50,6 +50,276 @@ housing_benefit_special_earnings_disregard_eligible: [true, true, false, true, false, true, false, false, true, false] housing_benefit_special_earnings_disregard: [1040, 1040, 260, 1040, 260, 1040, 260, 260, 1040, 260] +# Cases: SDA; WTC disability; WTC severe disability; AA; DLA mobility; +# PIP daily living; PIP mobility; AFIP; WTC element without an award; +# WTC award without either disability element. Each claimant is pension-age, +# is not a lone parent and has more than £20 a week in net earnings. +- name: ENGLAND pension-age qualifying receipt matrix + period: 2024 + input: + country: [ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND, ENGLAND] + age: [70, 70, 70, 70, 70, 70, 70, 70, 70, 70] + housing_benefit_pension_age_regulations_apply: [true, true, true, true, true, true, true, true, true, true] + carer_premium: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + sda: [100, 0, 0, 0, 0, 0, 0, 0, 0, 0] + working_tax_credit: [0, 100, 100, 0, 0, 0, 0, 0, 0, 100] + WTC_disabled_element: [0, 100, 0, 0, 0, 0, 0, 0, 100, 0] + WTC_severely_disabled_element: [0, 0, 100, 0, 0, 0, 0, 0, 0, 0] + attendance_allowance: [0, 0, 0, 100, 0, 0, 0, 0, 0, 0] + dla_sc: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + dla_m: [0, 0, 0, 0, 100, 0, 0, 0, 0, 0] + pip_dl: [0, 0, 0, 0, 0, 100, 0, 0, 0, 0] + pip_m: [0, 0, 0, 0, 0, 0, 100, 0, 0, 0] + armed_forces_independence_payment: [0, 0, 0, 0, 0, 0, 0, 100, 0, 0] + is_blind: [false, false, false, false, false, false, false, false, false, false] + is_claimant_or_partner: [true, true, true, true, true, true, true, true, true, true] + housing_benefit_net_earnings: [5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000] + is_lone_parent: [false, false, false, false, false, false, false, false, false, false] + is_couple: [false, false, false, false, false, false, false, false, false, false] + output: + housing_benefit_special_earnings_disregard_eligible: [true, true, true, true, true, true, true, true, false, false] + housing_benefit_special_earnings_disregard: [1040, 1040, 1040, 1040, 1040, 1040, 1040, 1040, 260, 260] + +- name: ENGLAND pension-age partner SDA qualifies the couple + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + sda: 0 + partner: + age: 70 + is_claimant_or_partner: true + sda: 100 + benunits: + couple: + members: [claimant, partner] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + is_lone_parent: false + is_couple: true + housing_benefit_special_earnings_disregard_eligible: true + housing_benefit_special_earnings_disregard: 1040 + +- name: ENGLAND adult non-dependant SDA and disability WTC do not qualify the pension-age couple + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + sda: 0 + partner: + age: 70 + is_claimant_or_partner: true + sda: 0 + adult_non_dependant: + age: 40 + # Claimant of their own benefit unit, never of the pensioner couple. + is_claimant_or_partner: true + sda: 100 + benunits: + couple: + members: [claimant, partner] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + WTC_disabled_element: 0 + WTC_severely_disabled_element: 0 + non_dependant: + members: [adult_non_dependant] + housing_benefit_pension_age_regulations_apply: false + housing_benefit_net_earnings: 0 + disability_premium: 0 + severe_disability_premium: 0 + working_tax_credit: 100 + WTC_disabled_element: 100 + WTC_severely_disabled_element: 0 + households: + household: + members: [claimant, partner, adult_non_dependant] + country: ENGLAND + output: + is_lone_parent: [false, false] + is_couple: [true, false] + housing_benefit_special_earnings_disregard_eligible: [false, false] + housing_benefit_special_earnings_disregard: [520, 0] + +- name: ENGLAND qualifying young person's disability receipt does not qualify the pension-age claimant + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + pip_m: 0 + qualifying_young_person: + age: 18 + is_claimant_or_partner: false + is_in_non_advanced_education: true + pip_m: 100 + benunits: + family: + members: [claimant, qualifying_young_person] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + # Isolate receipt scoping from the independent lone-parent route. + is_lone_parent: false + households: + household: + members: [claimant, qualifying_young_person] + country: ENGLAND + output: + is_couple: false + housing_benefit_special_earnings_disregard_eligible: false + housing_benefit_special_earnings_disregard: 260 + +# Cases: SDA; WTC disability; WTC severe disability; AA; DLA mobility; +# PIP daily living; PIP mobility; AFIP; WTC element without an award; +# WTC award without either disability element. Each claimant is pension-age, +# is not a lone parent and has more than £20 a week in net earnings. +- name: NORTHERN_IRELAND pension-age qualifying receipt matrix + period: 2024 + input: + country: [NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND, NORTHERN_IRELAND] + age: [70, 70, 70, 70, 70, 70, 70, 70, 70, 70] + housing_benefit_pension_age_regulations_apply: [true, true, true, true, true, true, true, true, true, true] + carer_premium: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + sda: [100, 0, 0, 0, 0, 0, 0, 0, 0, 0] + working_tax_credit: [0, 100, 100, 0, 0, 0, 0, 0, 0, 100] + WTC_disabled_element: [0, 100, 0, 0, 0, 0, 0, 0, 100, 0] + WTC_severely_disabled_element: [0, 0, 100, 0, 0, 0, 0, 0, 0, 0] + attendance_allowance: [0, 0, 0, 100, 0, 0, 0, 0, 0, 0] + dla_sc: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + dla_m: [0, 0, 0, 0, 100, 0, 0, 0, 0, 0] + pip_dl: [0, 0, 0, 0, 0, 100, 0, 0, 0, 0] + pip_m: [0, 0, 0, 0, 0, 0, 100, 0, 0, 0] + armed_forces_independence_payment: [0, 0, 0, 0, 0, 0, 0, 100, 0, 0] + is_blind: [false, false, false, false, false, false, false, false, false, false] + is_claimant_or_partner: [true, true, true, true, true, true, true, true, true, true] + housing_benefit_net_earnings: [5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000] + is_lone_parent: [false, false, false, false, false, false, false, false, false, false] + is_couple: [false, false, false, false, false, false, false, false, false, false] + output: + housing_benefit_special_earnings_disregard_eligible: [true, true, true, true, true, true, true, true, false, false] + housing_benefit_special_earnings_disregard: [1040, 1040, 1040, 1040, 1040, 1040, 1040, 1040, 260, 260] + +- name: NORTHERN_IRELAND pension-age partner SDA qualifies the couple + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + sda: 0 + partner: + age: 70 + is_claimant_or_partner: true + sda: 100 + benunits: + couple: + members: [claimant, partner] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + households: + household: + members: [claimant, partner] + country: NORTHERN_IRELAND + output: + is_lone_parent: false + is_couple: true + housing_benefit_special_earnings_disregard_eligible: true + housing_benefit_special_earnings_disregard: 1040 + +- name: NORTHERN_IRELAND adult non-dependant SDA and disability WTC do not qualify the pension-age couple + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + sda: 0 + partner: + age: 70 + is_claimant_or_partner: true + sda: 0 + adult_non_dependant: + age: 40 + # Claimant of their own benefit unit, never of the pensioner couple. + is_claimant_or_partner: true + sda: 100 + benunits: + couple: + members: [claimant, partner] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + WTC_disabled_element: 0 + WTC_severely_disabled_element: 0 + non_dependant: + members: [adult_non_dependant] + housing_benefit_pension_age_regulations_apply: false + housing_benefit_net_earnings: 0 + disability_premium: 0 + severe_disability_premium: 0 + working_tax_credit: 100 + WTC_disabled_element: 100 + WTC_severely_disabled_element: 0 + households: + household: + members: [claimant, partner, adult_non_dependant] + country: NORTHERN_IRELAND + output: + is_lone_parent: [false, false] + is_couple: [true, false] + housing_benefit_special_earnings_disregard_eligible: [false, false] + housing_benefit_special_earnings_disregard: [520, 0] + +- name: NORTHERN_IRELAND qualifying young person's disability receipt does not qualify the pension-age claimant + period: 2024 + input: + people: + claimant: + age: 72 + is_claimant_or_partner: true + pip_m: 0 + qualifying_young_person: + age: 18 + is_claimant_or_partner: false + is_in_non_advanced_education: true + pip_m: 100 + benunits: + family: + members: [claimant, qualifying_young_person] + housing_benefit_pension_age_regulations_apply: true + housing_benefit_net_earnings: 5000 + carer_premium: 0 + working_tax_credit: 0 + # Isolate receipt scoping from the independent lone-parent route. + is_lone_parent: false + households: + household: + members: [claimant, qualifying_young_person] + country: NORTHERN_IRELAND + output: + is_couple: false + housing_benefit_special_earnings_disregard_eligible: false + housing_benefit_special_earnings_disregard: 260 + - name: Standard replacement, lone-parent precedence and earnings cap period: 2024 input: @@ -62,6 +332,7 @@ meets_housing_benefit_additional_earnings_disregard_conditions: [false, false, false, false, false, false] housing_benefit_on_passporting_benefit: [false, false, false, false, false, false] output: + housing_benefit_special_earnings_disregard: [1040, 1040, 1300, 500, 260, 520] housing_benefit_applicable_income_disregard: [1040, 1040, 1300, 500, 260, 520] - name: Special standard amount participates in additional-disregard threshold diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py index 5c15427da6..0eafd1c2e3 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py @@ -2,6 +2,36 @@ class housing_benefit_special_earnings_disregard_eligible(Variable): + """Implement the modelled pension-age paragraph 5 qualifying routes. + + GB SI 2006/214 Sch. 4 and NI SR 2006/406 Sch. 5 para. 5(1)(a) + list the following receipts implemented here: + (ii) "severe disablement allowance under section 68 of the Act"; + (iii) "attendance allowance"; + (iv) "disability living allowance under sections 71 to 76 of the Act"; + (vi) "the disability element or the severe disability element of working + tax credit"; and (viii) "personal independence payment". + GB (ix) and NI (iva) list "armed forces independence payment". + Para. 5(1)(b) adds those "certified as severely sight impaired or blind + by a consultant ophthalmologist", represented here by is_blind. + + Only claimant/partner receipts qualify. Working Tax Credit and its + disability elements are benefit-unit variables whose formulas scope + entitlement to the claimant/partner; a positive element alone does not + establish receipt of the award. + + Not derived: long-term Incapacity Benefit (5(1)(a)(i)), prescribed war + pension/personal injury mobility supplements (v), main-phase ESA (vii), + continuous incapacity for 196 days if terminally ill or 364 days otherwise + (5(1)(c)), assessed LCW/LCWRA or the specified assessment-phase exceptions + (5(1)(d)), and protected earlier HB awards with eight-week continuity + (5(2)-(3)). Pension age disability payment, Scottish adult disability + living allowance and adult disability payment also lack dedicated + variables here; their upstream mapping to AA/DLA/PIP is not verified. + These residual routes require pre-assessed eligibility. Existing benefit + qualification approximations are inherited. + """ + value_type = bool entity = BenUnit definition_period = YEAR @@ -37,6 +67,7 @@ def formula(benunit, period, parameters): person, period, [ + "sda", "attendance_allowance", "dla_sc", "dla_m", @@ -47,10 +78,24 @@ def formula(benunit, period, parameters): ) > 0 ) - pension = benunit.any( - person("is_claimant_or_partner", period) - & (qualifying_receipt | person("is_blind", period)) - ) | (benunit("carer_premium", period) > 0) + # Para. 5(1)(a)(vi), as in Pension Credit: disability-related WTC + # requires an actual award as well as a positive disability element. + wtc_disability_element = ( + add( + benunit, + period, + ["WTC_disabled_element", "WTC_severely_disabled_element"], + ) + > 0 + ) & (benunit("working_tax_credit", period) > 0) + pension = ( + benunit.any( + person("is_claimant_or_partner", period) + & (qualifying_receipt | person("is_blind", period)) + ) + | wtc_disability_element + | (benunit("carer_premium", period) > 0) + ) # Working-age carer/occupation routes restrict which person's/job's # earnings may be disregarded. Do not automatically apply £20 to an # ordinary earner solely because their non-earning partner cares. From 042260687fb98bd075e2b96dae8677c7eea302a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 01:24:46 -0400 Subject: [PATCH 5/7] Repair the cross-module HB disregard property oracle Apply lone-parent and special precedence and pin the 18/34 couple and disabled single regressions with explicit roles inherited from main. Co-Authored-By: Claude Opus 5.5 --- ...pensioner_earnings_disregard_properties.py | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index 922a844d15..95473e8c7f 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -31,16 +31,18 @@ Benefit's additional sum was £37.10 (SI 2020/371 reg 5) and CTR's stayed £17.10 (an intended difference, which the test pins). 6. The Housing Benefit disregard (#1908) is the statutory weekly sum x 52 in - every year from 2015 to 2030, at working and pension age: £5, £10 or £25 - (SI 2006/213 Sch 4 paras 4, 7, 10; SI 2006/214 Sch 4 paras 2, 7), plus - £17.10 (£37.10 in 2020, SI 2020/371 reg 5) where a work condition holds, + every year from 2015 to 2030, at working and pension age: £25 for a lone + parent, otherwise £20 for a special-qualifying family, otherwise £10 for + a couple or £5 for a single claimant (SI 2006/213 Sch 4; SI 2006/214 + Sch 4), plus £17.10 (£37.10 in 2020, SI 2020/371 reg 5) where a work + condition holds, for families with enough earnings and on none of Universal Credit, Income Support, income-based JSA or income-related ESA (whose working-age earnings are all disregarded, Sch 4 para 12). """ 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 @@ -306,16 +308,61 @@ def test_differential_against_the_housing_benefit_pension_age_disregard(units, y ), st.integers(2015, 2030), ) +@example( + units=[ + dict( + shape="couple", + nation="ENGLAND", + adults=[ + dict( + age=age, + employment_income=20_000.0, + weekly_hours=0.0, + disabled=False, + state_pension=0.0, + ) + for age in (18, 34) + ], + children=[], + ), + dict( + shape="single", + nation="ENGLAND", + adults=[ + dict( + age=40, + employment_income=20_000.0, + weekly_hours=0.0, + disabled=True, + state_pension=0.0, + ) + ], + children=[], + ), + ], + year=2026, +) def test_housing_benefit_disregard_is_the_statutory_weekly_sum_in_every_year( units, year ): - values = calculate(units, year=year, no_income_related_benefits=True) + values = calculate( + units, + year=year, + variables=VARIABLES + ["housing_benefit_special_earnings_disregard_eligible"], + no_income_related_benefits=True, + ) disregard = values["housing_benefit_applicable_income_disregard"] conditions = values[ "meets_housing_benefit_additional_earnings_disregard_conditions" ].astype(bool) + special = values["housing_benefit_special_earnings_disregard_eligible"].astype(bool) for i, unit in enumerate(units): - weekly = WEEKLY[unit["shape"]] + if unit["shape"] == "lone_parent": + weekly = 25 + elif special[i]: + weekly = 20 + else: + weekly = WEEKLY[unit["shape"]] if conditions[i]: weekly += 37.1 if year == 2020 else 17.1 assert abs(disregard[i] - weekly * WEEKS) < 0.01, (unit, year) From 1db09c3e86b670b44aa35775de71b40d20918c55 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 01:24:47 -0400 Subject: [PATCH 6/7] Strengthen HB standard cap and age comparison properties Test every family with eligibility held false and true, independently assert natural eligibility, freeze the special amount, and check selected-standard bounds and statutory precedence. Co-Authored-By: Claude Opus 5.5 --- ...g_benefit_earnings_disregard_properties.py | 152 +++++++++++++++--- 1 file changed, 131 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py index a79e88531b..475723401e 100644 --- a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py @@ -22,20 +22,14 @@ £37.10 (an intended difference). 5. The disregard reduces applicable income by no more than the claimant's and partner's gross earnings, and never raises it. -6. Metamorphic: the pension-age and working-age schedules agree. Moving every - adult between working age (25 to 60) and pension age (67 to 90), with - earnings below the tax and National Insurance thresholds, leaves the - disregard unchanged. It covers only those earnings and adults aged 25 or - over, where nothing age-dependent (para 12, the age-25 test, National - Insurance above State Pension age) can differ between the two groups, so - it pins the current behaviour rather than independently testing the two - schedules. Intended difference: the £20 special disregard has different - conditions at each age. At working age it follows the disability premium, - which the model grants on the disability flag the draws set; at pension age - it needs receipt of a listed benefit, blindness or the carer premium (SI - 2006/214 Sch 4 paras 4 and 5), none of which the draws supply. The - comparison therefore covers families whose special eligibility is the same - at both ages, which includes every family without a disabled adult. +6. Metamorphic: with special eligibility fixed identically false or true, + moving every adult between working age (25 to 60) and pension age (67 to + 90) leaves every family's disregard unchanged. Earnings stay below the + tax and National Insurance thresholds and every adult stays over 25. + Natural eligibility is checked independently: the disability flag qualifies + at working age, but the draws supply no pension-age qualifying receipt, + blindness or carer premium (SI 2006/214 Sch 4 paras 4 and 5). Lone parents + naturally keep the same £25 even when these eligibility flags differ. 7. Differential against the old flat formula (£5/£10/£25 by family type, plus £37.10 for summed hours over 30, or 16 for lone parents), read without CPI uprating. They agree for families whose net earnings cover the flat @@ -45,6 +39,10 @@ must differ where net earnings fall below the flat amount (the new disregard is the net earnings) and for families without earnings (the new disregard is zero). +8. The selected standard is min(net earnings, 52 times the statutory weekly + choice), with £25 lone-parent precedence, then £20 special, then £10 + couple, then £5 single. These amounts never stack and the selected + standard stays between zero and net earnings. Para 12 is tested on its own: with Income Support, every family has all its net earnings disregarded, at working or pension age. Leaving Income Support can @@ -57,7 +55,7 @@ """ 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 @@ -179,8 +177,14 @@ def situation(units, earnings_bump=0.0, income_support=0.0, age_map=None): ] -def calculate(units, **kwargs): +def calculate(units, special_eligible=None, **kwargs): sim = Simulation(situation=situation(units, **kwargs)) + if special_eligible is not None: + sim.set_input( + "housing_benefit_special_earnings_disregard_eligible", + YEAR, + np.full(len(units), special_eligible), + ) return {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in VARIABLES} @@ -229,6 +233,7 @@ def test_weekly_amounts_are_frozen_from_2015_to_2030(): for year in range(2015, 2031): p = system.parameters(year).gov.dwp.housing_benefit.means_test.income_disregard assert (p.single, p.couple, p.lone_parent) == (5, 10, 25), year + assert p.special == 20, year # SI 2020/371 reg 5: £37.10 from 6 April 2020 to 4 April 2021, which # the model's fiscal-year convention reads as 2020. expected_worker = 37.1 if year == 2020 else 17.1 @@ -258,19 +263,124 @@ def test_disregard_lowers_applicable_income_by_no_more_than_earnings(units): min_size=1, ) ) +@example( + units=[ + dict( + shape=shape, + adults=[ + dict( + age=40, + employment_income=2_000.0, + weekly_hours=0.0, + disabled=True, + pension_contributions=0.0, + ) + for _ in range(n_adults) + ], + children=[5] * n_children, + childcare=0.0, + ) + for shape, (n_adults, n_children) in SHAPES.items() + ] +) def test_pension_age_and_working_age_schedules_agree(units): working_age = calculate(units) + # Map 25-60 onto 67-90, keeping everyone at or over 25. - pension_age = calculate(units, age_map=lambda age: 67 + (age - 25) * 23 // 35) + def pension_age_map(age): + return 67 + (age - 25) * 23 // 35 + + pension_age = calculate(units, age_map=pension_age_map) key = "housing_benefit_applicable_income_disregard" special = "housing_benefit_special_earnings_disregard_eligible" for i, unit in enumerate(units): - if not any(adult["disabled"] for adult in unit["adults"]): - # Nothing else in the draws can make the two routes differ. - assert working_age[special][i] == pension_age[special][i], unit - if working_age[special][i] == pension_age[special][i]: + assert working_age[special][i] == any( + adult["disabled"] for adult in unit["adults"] + ), unit + assert not pension_age[special][i], unit + if unit["shape"] == "lone_parent": assert abs(working_age[key][i] - pension_age[key][i]) < 0.01, unit + for eligible in (False, True): + working_age_fixed = calculate(units, special_eligible=eligible) + pension_age_fixed = calculate( + units, special_eligible=eligible, age_map=pension_age_map + ) + for i, unit in enumerate(units): + assert working_age_fixed[special][i] == eligible, unit + assert pension_age_fixed[special][i] == eligible, unit + assert abs(working_age_fixed[key][i] - pension_age_fixed[key][i]) < 0.01, ( + unit, + eligible, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + st.tuples( + st.sampled_from(sorted(SHAPES)), + st.integers(0, 100_000_000).map(lambda pennies: pennies / 100), + st.booleans(), + ), + min_size=1, + max_size=30, + ) +) +@example( + cases=[ + (shape, net, eligible) + for shape in SHAPES + for net in (0, 259.99, 260, 500, 520, 1039.99, 1040, 1300, 2000) + for eligible in (False, True) + ] +) +def test_selected_standard_has_statutory_precedence_and_is_capped_at_net_earnings( + cases, +): + units = [] + for shape, _, _ in cases: + n_adults, n_children = SHAPES[shape] + units.append( + dict( + shape=shape, + adults=[ + dict( + age=40, + employment_income=0.0, + weekly_hours=0.0, + disabled=False, + pension_contributions=0.0, + ) + for _ in range(n_adults) + ], + children=[5] * n_children, + childcare=0.0, + ) + ) + sim = Simulation(situation=situation(units)) + # Supply net earnings independently of taxes, pensions and gross income: + # this property tests the selected standard before downstream composition. + sim.set_input("housing_benefit_net_earnings", YEAR, [case[1] for case in cases]) + sim.set_input( + "housing_benefit_special_earnings_disregard_eligible", + YEAR, + [case[2] for case in cases], + ) + selected = sim.calculate("housing_benefit_special_earnings_disregard", YEAR) + net_earnings = sim.calculate("housing_benefit_net_earnings", YEAR) + for actual, net, (shape, _, eligible) in zip(selected, net_earnings, cases): + if shape == "lone_parent": + weekly = 25 + elif eligible: + weekly = 20 + elif SHAPES[shape][0] == 2: + weekly = 10 + else: + weekly = 5 + assert actual == min(net, WEEKS * weekly), (shape, net, eligible) + assert 0 <= actual <= net, (shape, net, eligible) + @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=30)) From 05246aa1da7df24ffde0c6b40b7ce5bef4f22ef7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 05:00:42 -0400 Subject: [PATCH 7/7] Pin the working-age special disregard for a disabled single MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-module property reads special eligibility from the model, so a regression that made a disabled working-age single ineligible would still pass it. A deterministic test now asserts eligibility and the £1,040 standard directly (no hours, so no additional sum; tax credits have ended by 2026, so the working tax credit route is off). The eligibility docstring now names the working-age premium route too, and the Housing Benefit property docstring notes that its pension-age "not special" check relies on YEAR falling after tax credits end. Co-Authored-By: Claude Opus 5.5 --- ...pensioner_earnings_disregard_properties.py | 29 +++++++++++++++++++ ...g_benefit_earnings_disregard_properties.py | 5 +++- ...fit_special_earnings_disregard_eligible.py | 3 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py index 95473e8c7f..c40751e943 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_earnings_disregard_properties.py @@ -366,3 +366,32 @@ def test_housing_benefit_disregard_is_the_statutory_weekly_sum_in_every_year( if conditions[i]: weekly += 37.1 if year == 2020 else 17.1 assert abs(disregard[i] - weekly * WEEKS) < 0.01, (unit, year) + + +def test_disabled_working_age_single_gets_the_special_hb_disregard(): + # The property above reads special eligibility from the model, so pin it + # here. With no hours the additional sum cannot apply, and tax credits + # have ended by 2026, so the working tax credit route is off. + unit = dict( + shape="single", + nation="ENGLAND", + children=[], + adults=[ + dict( + age=40, + employment_income=20_000.0, + weekly_hours=0.0, + disabled=True, + state_pension=0.0, + ) + ], + ) + values = calculate( + [unit], + year=2026, + variables=VARIABLES + ["housing_benefit_special_earnings_disregard_eligible"], + no_income_related_benefits=True, + ) + assert values["housing_benefit_special_earnings_disregard_eligible"][0] + disregard = values["housing_benefit_applicable_income_disregard"][0] + assert abs(disregard - 20 * WEEKS) < 0.01 diff --git a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py index 475723401e..ce2b185f9e 100644 --- a/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py @@ -28,7 +28,10 @@ tax and National Insurance thresholds and every adult stays over 25. Natural eligibility is checked independently: the disability flag qualifies at working age, but the draws supply no pension-age qualifying receipt, - blindness or carer premium (SI 2006/214 Sch 4 paras 4 and 5). Lone parents + blindness or carer premium (SI 2006/214 Sch 4 paras 4 and 5), and YEAR + falls after tax credits end in the model (6 April 2025, tax_credits.active), + so the disability-element route of working tax credit cannot apply. A YEAR + before 2025-26 would need that route excluded from the draws. Lone parents naturally keep the same £25 even when these eligibility flags differ. 7. Differential against the old flat formula (£5/£10/£25 by family type, plus £37.10 for summed hours over 30, or 16 for lone parents), read without diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py index 0eafd1c2e3..f1735a222f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_special_earnings_disregard_eligible.py @@ -2,7 +2,8 @@ class housing_benefit_special_earnings_disregard_eligible(Variable): - """Implement the modelled pension-age paragraph 5 qualifying routes. + """Implement the working-age premium route and the modelled pension-age + paragraph 5 qualifying routes. GB SI 2006/214 Sch. 4 and NI SR 2006/406 Sch. 5 para. 5(1)(a) list the following receipts implemented here: