diff --git a/changelog.d/2610.fixed.md b/changelog.d/2610.fixed.md new file mode 100644 index 0000000000..638605ec2d --- /dev/null +++ b/changelog.d/2610.fixed.md @@ -0,0 +1 @@ +- Restored neutral pre-2021 Housing Benefit and Council Tax Reduction cohort flags while retaining the existing FY2021-22 annual split. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pre_cutoff_period.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pre_cutoff_period.yaml new file mode 100644 index 0000000000..ed35677dd7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pre_cutoff_period.yaml @@ -0,0 +1,101 @@ +# Component regressions: upstream pensioner flags explicitly isolate allowance +# selection. These cases do not claim natural pensioner eligibility or cash awards. +# GB SI 2006/214 Sch 3 para 1 at 30 April 2020 and England SI 2012/2885 Sch 2 para 1 +# supply 187.75/280.85 weekly. Model annualisation uses 52 weeks. +- name: Case 1, omitted-age component has the single higher allowance in 2020. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + person1: {} + benunits: + benunit: + members: [person1] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + council_tax_reduction_pensioner: true + benefits_premiums: 0 + council_tax_reduction_premiums: 0 + households: + household: + members: [person1] + region: LONDON + output: + housing_benefit_applicable_amount: 9_763 + council_tax_reduction_applicable_amount: 9_763 + +- name: Case 2, isolated couple component has the higher allowance in 2020. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + person2: + age: 40 + benunits: + benunit: + members: [person1, person2] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + council_tax_reduction_pensioner: true + benefits_premiums: 0 + council_tax_reduction_premiums: 0 + households: + household: + members: [person1, person2] + region: LONDON + output: + housing_benefit_applicable_amount: 14_604.2 + council_tax_reduction_applicable_amount: 14_604.2 + +# Annual 2021 means FY2021-22, not legal commencement on 1 January. +# SI 2021/188 reg 2 and NI SR 2021/70 reg 2 insert 177.10 weekly; +# England SI 2021/29 regs 1(2), 7 insert the same lower component. +- name: Case 3, post-cutoff single component retains the lower allowance in 2021. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + is_male: true + benunits: + benunit: + members: [person1] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + council_tax_reduction_pensioner: true + benefits_premiums: 0 + council_tax_reduction_premiums: 0 + households: + household: + members: [person1] + region: LONDON + output: + housing_benefit_applicable_amount: 9_209.2 + council_tax_reduction_applicable_amount: 9_209.2 + +# 191.15 is pinned here only for England CTR, directly by SI 2021/29 reg 7. +- name: Case 4, England CTR retains the higher allowance for pre-cutoff attainment. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + date_of_birth: 19550331 + is_male: true + benunits: + benunit: + members: [person1] + council_tax_reduction_pensioner: true + benefits_premiums: 0 + council_tax_reduction_premiums: 0 + households: + household: + members: [person1] + region: LONDON + output: + state_pension_age_date: 20210331 + council_tax_reduction_applicable_amount: 9_939.8 diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 3334321751..404325191b 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -309,3 +309,174 @@ def test_other_members_do_not_change_the_cohort(units, others, year): plain.calculate(variable, year), atol=0.005, ) + + +# SI 2021/188 reg 1(2), NI SR 2021/70 reg 1(2), and SI 2021/29 reg 1(2) +# introduce the cohort split for FY2021-22. Annual 2021 represents that fiscal +# year; it does not claim that these provisions commenced on 1 January 2021. +COHORT_VARIABLES = ( + "housing_benefit_attained_pension_age_before_cutoff", + "council_tax_reduction_attained_pension_age_before_cutoff", +) + + +def cutoff_situation(year, people=None, region="LONDON", component=False): + """Build public situations; component flags isolate allowances, not eligibility.""" + if people is None: + people = {"person1": {"age": {year: 40}, "is_male": {year: True}}} + benunit = {"members": list(people)} + if component: + benunit.update( + { + "housing_benefit_eligible": {year: True}, + "housing_benefit_pension_age_regulations_apply": {year: True}, + "council_tax_reduction_pensioner": {year: True}, + "benefits_premiums": {year: 0}, + "council_tax_reduction_premiums": {year: 0}, + } + ) + return { + "people": people, + "benunits": {"benunit": benunit}, + "households": { + "household": {"members": list(people), "region": {year: region}} + }, + } + + +@pytest.mark.parametrize("variable", COHORT_VARIABLES, ids=["hb", "ctr"]) +@pytest.mark.parametrize("year", [2015, 2020, 2021]) +def test_pre2021_cutoff_is_neutral_without_entitlement_overrides(variable, year): + """A neutral historical cohort flag does not award pensioner eligibility.""" + sim = Simulation(situation=cutoff_situation(year)) + assert sim.calculate("state_pension_age_date", year)[0] > 20210401 + assert bool(sim.calculate(variable, year)[0]) is (year < 2021) + + +@pytest.mark.parametrize("region", ["LONDON", "SCOTLAND", "WALES", "NORTHERN_IRELAND"]) +@pytest.mark.parametrize("year", [2015, 2020]) +def test_pre2021_neutral_cohort_does_not_depend_on_region(year, region): + """The CTR helper is neutral; this does not extend English monetary rules.""" + sim = Simulation(situation=cutoff_situation(year, region=region)) + for variable in COHORT_VARIABLES: + assert bool(sim.calculate(variable, year)[0]) is True + + +@pytest.mark.parametrize("year", [2021, 2025]) +@pytest.mark.parametrize( + "birth, attainment, expected", + [ + (19550331, 20210331, True), + (19550401, 20210401, False), + (19550402, 20210402, False), + ], +) +def test_post2021_exact_attainment_boundary_is_preserved( + year, birth, attainment, expected +): + """These men attain 66 on their actual 1955 birth-date anniversary.""" + people = { + "person1": { + "age": {year: year - 1955}, + "date_of_birth": {year: birth}, + "is_male": {year: True}, + } + } + sim = Simulation(situation=cutoff_situation(year, people)) + assert sim.calculate("state_pension_age_date", year)[0] == attainment + for variable in COHORT_VARIABLES: + assert bool(sim.calculate(variable, year)[0]) is expected + + +@pytest.mark.parametrize("year", [2021, 2025]) +@pytest.mark.parametrize("qualifying_role", ["partner", "other"]) +def test_post2021_only_claimant_or_partner_can_supply_earlier_attainment( + year, qualifying_role +): + """An earlier-attaining partner counts; an unrelated member does not.""" + people = {} + for name, birth, role in [ + ("person1", 19550402, True), + ("person2", 19550331, qualifying_role == "partner"), + ]: + people[name] = { + "age": {year: year - 1955}, + "date_of_birth": {year: birth}, + "is_male": {year: True}, + "is_claimant_or_partner": {year: role}, + "is_council_tax_reduction_applicant_or_partner": {year: role}, + } + sim = Simulation(situation=cutoff_situation(year, people)) + for variable in COHORT_VARIABLES: + assert bool(sim.calculate(variable, year)[0]) is (qualifying_role == "partner") + + +@pytest.mark.parametrize("year", [2021, 2025]) +def test_post2021_cutoff_parameter_remains_reformable(year): + """Moving the cutoff one day includes an otherwise identical 1 April cohort.""" + from policyengine_uk.model_api import Scenario + + people = { + "person1": { + "age": {year: year - 1955}, + "date_of_birth": {year: 19550401}, + "is_male": {year: True}, + } + } + situation = cutoff_situation(year, people) + baseline = Simulation(situation=situation) + reformed = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.housing_benefit.allowances.pension_age_cutoff": { + str(year): 20210402 + } + } + ), + ) + for variable in COHORT_VARIABLES: + assert bool(reformed.calculate(variable, year)[0]) is True + assert bool(baseline.calculate(variable, year)[0]) is False + + +@pytest.mark.parametrize("year", [2015, 2020]) +def test_omitted_age_component_keeps_existing_higher_allowance(year): + """No age/DOB inputs: isolate the component, not natural pensioner entitlement.""" + sim = Simulation( + situation=cutoff_situation(year, people={"person1": {}}, component=True) + ) + for variable in COHORT_VARIABLES: + assert bool(sim.calculate(variable, year)[0]) is True + higher = sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.housing_benefit.allowances.single.aged + # 2015 uses the existing model rate symbolically; historical rate repair is #2207. + for variable in [ + "housing_benefit_applicable_amount", + "council_tax_reduction_applicable_amount", + ]: + assert sim.calculate(variable, year)[0] == pytest.approx(higher * 52, abs=0.01) + + +@pytest.mark.parametrize("region", ["SCOTLAND", "WALES", "NORTHERN_IRELAND"]) +def test_nonenglish_ctr_component_keeps_existing_higher_route(region): + """England's cutoff does not introduce a new monetary gate elsewhere.""" + year = 2021 + sim = Simulation(situation=cutoff_situation(year, region=region, component=True)) + assert bool(sim.calculate(COHORT_VARIABLES[1], year)[0]) is False + higher = sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.housing_benefit.allowances.single.aged + assert sim.calculate("council_tax_reduction_applicable_amount", year)[ + 0 + ] == pytest.approx(higher * 52, abs=0.01) + + +@pytest.mark.parametrize("variable", COHORT_VARIABLES, ids=["hb", "ctr"]) +def test_explicit_historical_cohort_input_remains_authoritative(variable): + year = 2020 + situation = cutoff_situation(year) + situation["benunits"]["benunit"][variable] = {year: False} + sim = Simulation(situation=situation) + assert bool(sim.calculate(variable, year)[0]) is False diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py index 4ef0a58602..b49ac4371c 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py @@ -12,16 +12,25 @@ class housing_benefit_attained_pension_age_before_cutoff(Variable): "did get the lower one. Other members of the benefit unit do not " "count. Before the " "cutoff took effect there was a single pension-age allowance, the " - "higher one, so this is true for every unit then." + "higher one, so this is true for every unit then. Annual 2021 " + "represents FY2021-22 under the model fiscal-year convention; the " + "cohort split commenced on 1 April 2021. This flag does not decide " + "pension-age eligibility." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", "https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made", "https://www.legislation.gov.uk/nisr/2006/406/schedule/4", + "https://www.legislation.gov.uk/uksi/2021/188/regulation/1/made", + "https://www.legislation.gov.uk/nisr/2021/70/regulation/1/made", + "https://www.legislation.gov.uk/nisr/2021/70/regulation/2/made", ) def formula(benunit, period, parameters): + return np.ones(benunit.count, dtype=bool) + + def formula_2021(benunit, period, parameters): cutoff = parameters( period ).gov.dwp.housing_benefit.allowances.pension_age_cutoff diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py index 6e2374f16d..e3c9a01d53 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py @@ -12,12 +12,22 @@ class council_tax_reduction_attained_pension_age_before_cutoff(Variable): "pension-age personal allowance; other members of the household do " "not count. Before the cutoff took effect there was a single " "pension-age allowance, the higher one, so this is true for every unit " - "then." + "then. Annual 2021 represents FY2021-22 under the model fiscal-year " + "convention: the English amendment applies to financial years " + "beginning on or after 1 April 2021. This flag does not decide " + "pension-age eligibility." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2" + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2", + "https://www.legislation.gov.uk/uksi/2021/29/regulation/1/made", + "https://www.legislation.gov.uk/uksi/2021/29/regulation/7/made", + ) def formula(benunit, period, parameters): + return np.ones(benunit.count, dtype=bool) + + def formula_2021(benunit, period, parameters): cutoff = parameters( period ).gov.dwp.housing_benefit.allowances.pension_age_cutoff