From 1b69b848e149e51b4b1fbec496a145d1a5b291a8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:09:44 -0400 Subject: [PATCH 01/24] Add HBAI and claimant-or-partner variables; deprecate generic child and adult flags Co-Authored-By: Claude Opus 5.5 --- .../equiv/child_age_threshold.yaml | 9 ++ .../hbai/dependent_child/age_limit.yaml | 9 ++ .../young_person_age_limit.yaml | 9 ++ .../test_no_generic_child_adult_flags.py | 96 +++++++++++++++++++ ...ild_or_young_person_for_legacy_benefits.py | 32 +++++++ ...fying_young_person_for_child_tax_credit.py | 28 ++++++ ...ild_or_young_person_for_legacy_benefits.py | 21 ++++ ...fying_young_person_for_universal_credit.py | 27 ++++++ .../dwp/universal_credit/is_uc_claimant.py | 28 ++---- .../household/demographic/adult_index.py | 10 +- .../benunit/benunit_count_adults.py | 7 +- .../benunit/benunit_count_children.py | 7 +- .../benunit/eldest_claimant_or_partner_age.py | 24 +++++ .../youngest_claimant_or_partner_age.py | 24 +++++ .../household/demographic/child_index.py | 7 +- .../household/demographic/eldest_adult_age.py | 7 +- .../household/demographic/eldest_child_age.py | 7 +- .../household/demographic/family_type.py | 7 +- .../demographic/hbai/hbai_person_type.py | 33 +++++++ .../demographic/hbai/is_hbai_adult.py | 16 ++++ .../hbai/is_hbai_child_aged_14_or_over.py | 19 ++++ .../hbai/is_hbai_child_under_14.py | 19 ++++ .../hbai/is_hbai_dependent_child.py | 57 +++++++++++ .../demographic/hbai/is_hbai_pensioner.py | 16 ++++ .../hbai/is_hbai_working_age_adult.py | 16 ++++ .../household/demographic/is_WA_adult.py | 7 +- .../household/demographic/is_adult.py | 7 +- .../demographic/is_benunit_eldest_child.py | 7 +- .../household/demographic/is_child.py | 7 +- .../demographic/is_claimant_or_partner.py | 32 +++++++ .../household/demographic/is_eldest_child.py | 8 +- .../household/demographic/is_married.py | 9 +- .../household/demographic/is_older_child.py | 7 +- .../household/demographic/is_young_child.py | 7 +- .../household/demographic/marital_status.py | 8 +- .../household/demographic/num_adults.py | 7 +- .../household/demographic/num_children.py | 7 +- .../demographic/num_disabled_adults.py | 7 +- .../demographic/num_disabled_children.py | 7 +- .../num_enhanced_disabled_adults.py | 7 +- .../num_enhanced_disabled_children.py | 7 +- .../num_severely_disabled_adults.py | 7 +- .../num_severely_disabled_children.py | 7 +- .../household/demographic/relation_type.py | 20 +++- .../demographic/youngest_adult_age.py | 7 +- .../demographic/youngest_child_age.py | 7 +- .../variables/household/income/is_couple.py | 3 +- .../household/income/is_lone_parent.py | 18 +++- .../variables/household/income/is_single.py | 3 +- .../household/income/is_single_person.py | 13 ++- 50 files changed, 697 insertions(+), 64 deletions(-) create mode 100644 policyengine_uk/parameters/household/demographic/equiv/child_age_threshold.yaml create mode 100644 policyengine_uk/parameters/household/demographic/hbai/dependent_child/age_limit.yaml create mode 100644 policyengine_uk/parameters/household/demographic/hbai/dependent_child/young_person_age_limit.yaml create mode 100644 policyengine_uk/tests/code_health/test_no_generic_child_adult_flags.py create mode 100644 policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit.py create mode 100644 policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/is_responsible_for_child_or_qualifying_young_person_for_universal_credit.py create mode 100644 policyengine_uk/variables/household/demographic/benunit/eldest_claimant_or_partner_age.py create mode 100644 policyengine_uk/variables/household/demographic/benunit/youngest_claimant_or_partner_age.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/hbai_person_type.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_adult.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_child_aged_14_or_over.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_child_under_14.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_pensioner.py create mode 100644 policyengine_uk/variables/household/demographic/hbai/is_hbai_working_age_adult.py create mode 100644 policyengine_uk/variables/household/demographic/is_claimant_or_partner.py diff --git a/policyengine_uk/parameters/household/demographic/equiv/child_age_threshold.yaml b/policyengine_uk/parameters/household/demographic/equiv/child_age_threshold.yaml new file mode 100644 index 0000000000..975a248143 --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/equiv/child_age_threshold.yaml @@ -0,0 +1,9 @@ +description: Age from which a dependent child takes the older-child weight in the Households Below Average Income modified OECD equivalence scale. +values: + 2010-04-01: 14 +metadata: + unit: year + label: HBAI equivalence scale older-child age + reference: + - title: HBAI background information and methodology FYE 2025, Table 4 (equivalence scales) + href: https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#equivalisation-1 diff --git a/policyengine_uk/parameters/household/demographic/hbai/dependent_child/age_limit.yaml b/policyengine_uk/parameters/household/demographic/hbai/dependent_child/age_limit.yaml new file mode 100644 index 0000000000..cd1938b97e --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/hbai/dependent_child/age_limit.yaml @@ -0,0 +1,9 @@ +description: Age below which every person is a dependent child in Households Below Average Income statistics. +values: + 0001-01-01: 16 +metadata: + unit: year + label: HBAI dependent child age limit + reference: + - title: HBAI background information and methodology FYE 2025, glossary (Child) + href: https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#child diff --git a/policyengine_uk/parameters/household/demographic/hbai/dependent_child/young_person_age_limit.yaml b/policyengine_uk/parameters/household/demographic/hbai/dependent_child/young_person_age_limit.yaml new file mode 100644 index 0000000000..1cc7f68e81 --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/hbai/dependent_child/young_person_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age below which a person in full-time non-advanced education or unwaged government training, living with parents and without a partner, is a dependent child in Households Below Average Income statistics. +values: + 0001-01-01: 20 +metadata: + unit: year + label: HBAI dependent young person age limit + reference: + - title: HBAI background information and methodology FYE 2025, glossary (Child) + href: https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#child diff --git a/policyengine_uk/tests/code_health/test_no_generic_child_adult_flags.py b/policyengine_uk/tests/code_health/test_no_generic_child_adult_flags.py new file mode 100644 index 0000000000..083e88e825 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_no_generic_child_adult_flags.py @@ -0,0 +1,96 @@ +"""No model code may read the deprecated generic child and adult variables. + +UK law has no single definition of a child or an adult: each programme sets +its own (Child Benefit, Universal Credit, tax credits, Pension Credit, the +legacy means-tested benefits, student support, ...), and statistics follow +the Households Below Average Income definitions. The generic variables below +use age-18 cut-offs with no legal basis. They are kept, with their original +formulas, only because downstream packages still read them. +""" + +import re +from pathlib import Path + +import pytest + +from policyengine_uk.system import system + +PACKAGE_ROOT = Path(__file__).resolve().parents[2] + +DEPRECATED = { + "is_child", + "is_adult", + "num_children", + "num_adults", + "benunit_count_children", + "benunit_count_adults", + "eldest_child_age", + "youngest_child_age", + "eldest_adult_age", + "youngest_adult_age", + "child_index", + "is_eldest_child", + "is_benunit_eldest_child", + "num_disabled_children", + "num_enhanced_disabled_children", + "num_severely_disabled_children", + "num_disabled_adults", + "num_enhanced_disabled_adults", + "num_severely_disabled_adults", + "family_type", + "is_WA_adult", + "is_young_child", + "is_older_child", +} + +# A shim may read another shim, so that each keeps its original formula. +SHIM_FILES = { + PACKAGE_ROOT / "variables" / "household" / "demographic" / f"{name}.py" + for name in DEPRECATED +} | { + PACKAGE_ROOT / "variables" / "household" / "demographic" / "benunit" / f"{name}.py" + for name in DEPRECATED +} + +REFERENCE = re.compile( + r"""["'](""" + "|".join(sorted(DEPRECATED, key=len, reverse=True)) + r""")["']""" +) + + +def _model_files(): + for path in sorted(PACKAGE_ROOT.rglob("*")): + if path.suffix not in (".py", ".yaml"): + continue + if "tests" in path.relative_to(PACKAGE_ROOT).parts: + continue + if path in SHIM_FILES: + continue + yield path + + +def test_shims_still_exist_for_downstream_users(): + missing = sorted(name for name in DEPRECATED if name not in system.variables) + assert not missing, f"Deprecated variables removed too early: {missing}" + + +def test_shims_are_labelled_deprecated(): + unlabelled = sorted( + name + for name in DEPRECATED + if "deprecated" not in (system.variables[name].label or "").lower() + ) + assert not unlabelled, ( + f"Deprecated variables without a deprecated label: {unlabelled}" + ) + + +@pytest.mark.parametrize( + "path", list(_model_files()), ids=lambda p: str(p.relative_to(PACKAGE_ROOT)) +) +def test_model_code_does_not_read_generic_child_or_adult_flags(path): + hits = sorted({m.group(1) for m in REFERENCE.finditer(path.read_text())}) + assert not hits, ( + f"{path.relative_to(PACKAGE_ROOT)} reads deprecated generic variables " + f"{hits}. Use the programme's own legal definition, an explicit age " + "predicate, or an HBAI variable (hbai in the name) instead." + ) diff --git a/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py new file mode 100644 index 0000000000..02d809d361 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class is_child_or_young_person_for_legacy_benefits(Variable): + """Child or young person in the family for the legacy means-tested schemes. + + Income Support, income-based Jobseeker's Allowance, income-related + Employment and Support Allowance, Housing Benefit and council tax + reduction schemes define a child as a person under 16 (SSCBA 1992 + s.137(1)) and a young person as a Child Benefit qualifying young person + (SSCBA 1992 s.142), excluding young persons entitled to a benefit in + their own right. That is the Child Benefit child or qualifying young + person test, restricted to members who are not the claimant or partner. + """ + + value_type = bool + entity = Person + label = "Child or young person for legacy means-tested benefits" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/14", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + "https://www.legislation.gov.uk/uksi/1996/207/regulation/76", + "https://www.legislation.gov.uk/uksi/2008/794/regulation/2", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + ) + + def formula(person, period, parameters): + return person( + "is_child_or_qualifying_young_person_for_child_benefit", period + ) & ~person("is_claimant_or_partner", period) diff --git a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit.py new file mode 100644 index 0000000000..17c5eaa8e4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit( + Variable +): + value_type = bool + entity = BenUnit + label = "Responsible for a child or qualifying young person for tax credits" + documentation = ( + "Whether the claimant (or either joint claimant) is responsible for " + "at least one child or qualifying young person under the Child Tax " + "Credit rules, which Working Tax Credit also applies. Benefit-unit " + "membership stands in for 'normally living with'." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/21/section/8", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/3", + "https://www.legislation.gov.uk/uksi/2002/2005/regulation/2", + ) + + def formula(benunit, period, parameters): + person = benunit.members + return benunit.any( + person("is_child_or_qualifying_young_person_for_child_tax_credit", period) + & ~person("is_claimant_or_partner", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_legacy_benefits.py new file mode 100644 index 0000000000..79309c9df7 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_legacy_benefits.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class is_responsible_for_child_or_young_person_for_legacy_benefits(Variable): + value_type = bool + entity = BenUnit + label = "Responsible for a child or young person for legacy means-tested benefits" + documentation = ( + "Whether the claimant or partner is responsible for a child or young " + "person in the same household, using benefit-unit membership." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + ) + + def formula(benunit, period, parameters): + return benunit.any( + benunit.members("is_child_or_young_person_for_legacy_benefits", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_responsible_for_child_or_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_responsible_for_child_or_qualifying_young_person_for_universal_credit.py new file mode 100644 index 0000000000..339a3eb57b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_responsible_for_child_or_qualifying_young_person_for_universal_credit.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_responsible_for_child_or_qualifying_young_person_for_universal_credit( + Variable +): + value_type = bool + entity = BenUnit + label = "Responsible for a child or qualifying young person for Universal Credit" + documentation = ( + "Whether the claimant (or either joint claimant) is responsible for " + "at least one child (under 16) or qualifying young person, using " + "benefit-unit membership for 'normally lives with'." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/40", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + ) + + def formula(benunit, period, parameters): + person = benunit.members + return benunit.any( + person("is_child_or_qualifying_young_person_for_universal_credit", period) + & ~person("is_uc_claimant", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_claimant.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_claimant.py index 4e758a7e7e..2033c754fb 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_claimant.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_claimant.py @@ -2,17 +2,13 @@ class is_uc_claimant(Variable): - """Claimant or partner, rather than a dependent member of the benefit unit. + """Claimant or partner, rather than a child or qualifying young person. - Datasets should supply this from recorded relationships. The calculator - fallback includes the family head, identified parents, and adults, except - UC qualifying young people in a benefit unit with an identified parent. - Education defaults to tertiary education at ages 18 and 19, so treating - those ages as dependants requires qualifying education and any applicable - entry and terminal-date inputs. A partner in qualifying education who is - not identified as a head or parent, or a dependent adult outside the UC - child definition, needs an explicit input when relationships remain - ambiguous. This flag does not establish eligibility. + A Universal Credit claim is made by a single claimant or by the members of + a couple jointly; children and qualifying young persons are people a + claimant is responsible for. This is the benefit unit's claimant or + partner (`is_claimant_or_partner`). It does not establish eligibility: + the UC age and other basic conditions are applied separately. """ value_type = bool @@ -21,17 +17,9 @@ class is_uc_claimant(Variable): definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/2012/5/section/39", + "https://www.legislation.gov.uk/ukpga/2012/5/section/40", "https://www.legislation.gov.uk/uksi/2013/376/regulation/3/2", - "https://www.gov.uk/universal-credit/eligibility", ) def formula(person, period, parameters): - has_parent = add(person.benunit, period, ["is_parent"]) > 0 - qualifying_child = person( - "is_child_or_qualifying_young_person_for_universal_credit", period - ) - return ( - person("is_benunit_head", period) - | person("is_parent", period) - | (person("is_adult", period) & ~(qualifying_child & has_parent)) - ) + return person("is_claimant_or_partner", period) diff --git a/policyengine_uk/variables/household/demographic/adult_index.py b/policyengine_uk/variables/household/demographic/adult_index.py index c2ae19384f..ff6181f5c8 100644 --- a/policyengine_uk/variables/household/demographic/adult_index.py +++ b/policyengine_uk/variables/household/demographic/adult_index.py @@ -5,7 +5,13 @@ class adult_index(Variable): value_type = int entity = Person - label = "Index of adult in household" + label = "Rank among household members aged 18 or over" + documentation = ( + "Rank by age, eldest first and starting at 1, among household " + "members aged 18 or over; 0 for everyone under 18. A modelling " + "index (for example, whose marginal tax rate is simulated), not a " + "legal definition of an adult." + ) definition_period = YEAR def formula(person, period, parameters): @@ -13,7 +19,7 @@ def formula(person, period, parameters): person.get_rank( person.household, -person("age", period), - condition=person("is_adult", period), + condition=~person("age_under_18", period), ) + 1 ) diff --git a/policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py b/policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py index 9ba2239025..afc38fa39e 100644 --- a/policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py +++ b/policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py @@ -4,7 +4,12 @@ class benunit_count_adults(Variable): value_type = int entity = BenUnit - label = "number of adults in the benefit unit" + label = "Members aged 18 or over in the benefit unit (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count of is_claimant_or_partner, or of is_hbai_adult for HBAI statistics instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/benunit/benunit_count_children.py b/policyengine_uk/variables/household/demographic/benunit/benunit_count_children.py index dba3a70eb6..5e436658d4 100644 --- a/policyengine_uk/variables/household/demographic/benunit/benunit_count_children.py +++ b/policyengine_uk/variables/household/demographic/benunit/benunit_count_children.py @@ -4,7 +4,12 @@ class benunit_count_children(Variable): value_type = int entity = BenUnit - label = "number of children in the benefit unit" + label = "Members aged under 18 in the benefit unit (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count of the programme's own child definition, or of is_hbai_dependent_child for HBAI statistics instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/benunit/eldest_claimant_or_partner_age.py b/policyengine_uk/variables/household/demographic/benunit/eldest_claimant_or_partner_age.py new file mode 100644 index 0000000000..1c69df34cf --- /dev/null +++ b/policyengine_uk/variables/household/demographic/benunit/eldest_claimant_or_partner_age.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class eldest_claimant_or_partner_age(Variable): + value_type = float + entity = BenUnit + label = "Age of the elder of the claimant and partner" + documentation = ( + "The age of the claimant, or of the older member of a couple. " + "Children and young persons in the family do not count. Negative " + "infinity if the benefit unit has no claimant." + ) + definition_period = YEAR + unit = "year" + + def formula(benunit, period, parameters): + person = benunit.members + return benunit.max( + where( + person("is_claimant_or_partner", period), + person("age", period), + -np.inf, + ) + ) diff --git a/policyengine_uk/variables/household/demographic/benunit/youngest_claimant_or_partner_age.py b/policyengine_uk/variables/household/demographic/benunit/youngest_claimant_or_partner_age.py new file mode 100644 index 0000000000..83ae365600 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/benunit/youngest_claimant_or_partner_age.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class youngest_claimant_or_partner_age(Variable): + value_type = float + entity = BenUnit + label = "Age of the younger of the claimant and partner" + documentation = ( + "The age of the claimant, or of the younger member of a couple. " + "Children and young persons in the family do not count. Infinity if " + "the benefit unit has no claimant." + ) + definition_period = YEAR + unit = "year" + + def formula(benunit, period, parameters): + person = benunit.members + return benunit.min( + where( + person("is_claimant_or_partner", period), + person("age", period), + np.inf, + ) + ) diff --git a/policyengine_uk/variables/household/demographic/child_index.py b/policyengine_uk/variables/household/demographic/child_index.py index 8548ba6cd0..7850a1e075 100644 --- a/policyengine_uk/variables/household/demographic/child_index.py +++ b/policyengine_uk/variables/household/demographic/child_index.py @@ -5,7 +5,12 @@ class child_index(Variable): value_type = int entity = Person - label = "Child reference number" + label = "Rank by age among benefit-unit members under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the programme's own child index (for example uc_child_index) instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/eldest_adult_age.py b/policyengine_uk/variables/household/demographic/eldest_adult_age.py index de775e51e5..7eccb02424 100644 --- a/policyengine_uk/variables/household/demographic/eldest_adult_age.py +++ b/policyengine_uk/variables/household/demographic/eldest_adult_age.py @@ -4,7 +4,12 @@ class eldest_adult_age(Variable): value_type = float entity = BenUnit - label = "Eldest adult age" + label = "Age of the eldest benefit-unit member aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use eldest_claimant_or_partner_age instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/eldest_child_age.py b/policyengine_uk/variables/household/demographic/eldest_child_age.py index d471c19e6a..ae1cb74105 100644 --- a/policyengine_uk/variables/household/demographic/eldest_child_age.py +++ b/policyengine_uk/variables/household/demographic/eldest_child_age.py @@ -4,7 +4,12 @@ class eldest_child_age(Variable): value_type = float entity = BenUnit - label = "Eldest adult age" + label = "Age of the eldest benefit-unit member under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the programme's own child definition instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/family_type.py b/policyengine_uk/variables/household/demographic/family_type.py index 26fb8e9134..eed1b138fd 100644 --- a/policyengine_uk/variables/household/demographic/family_type.py +++ b/policyengine_uk/variables/household/demographic/family_type.py @@ -13,7 +13,12 @@ class family_type(Variable): entity = BenUnit default_value = FamilyType.SINGLE possible_values = FamilyType - label = "Family composition" + label = "Family composition by age 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use relation_type with the programme's own responsibility for a child or young person (for example is_lone_parent for the legacy benefits), or hbai_person_type for HBAI statistics instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/hbai/hbai_person_type.py b/policyengine_uk/variables/household/demographic/hbai/hbai_person_type.py new file mode 100644 index 0000000000..082139025f --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/hbai_person_type.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class HBAIPersonType(Enum): + CHILD = "Dependent child" + WORKING_AGE_ADULT = "Working-age adult" + PENSIONER = "Pensioner" + + +class hbai_person_type(Variable): + value_type = Enum + possible_values = HBAIPersonType + default_value = HBAIPersonType.WORKING_AGE_ADULT + entity = Person + label = "Population group (HBAI definition)" + documentation = ( + "The Households Below Average Income population groups: dependent " + "children, working-age adults (below State Pension age) and " + "pensioners (at or above State Pension age). Every person is in " + "exactly one group." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#child" + + def formula(person, period, parameters): + return select( + [ + person("is_hbai_dependent_child", period), + person("is_hbai_working_age_adult", period), + ], + [HBAIPersonType.CHILD, HBAIPersonType.WORKING_AGE_ADULT], + default=HBAIPersonType.PENSIONER, + ) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_adult.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_adult.py new file mode 100644 index 0000000000..9008d4aabe --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_adult.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class is_hbai_adult(Variable): + value_type = bool + entity = Person + label = "Adult (HBAI definition)" + documentation = ( + "Households Below Average Income counts everyone who is not a " + "dependent child as an adult." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#adult" + + def formula(person, period, parameters): + return ~person("is_hbai_dependent_child", period) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_aged_14_or_over.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_aged_14_or_over.py new file mode 100644 index 0000000000..4226577276 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_aged_14_or_over.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class is_hbai_child_aged_14_or_over(Variable): + value_type = bool + entity = Person + label = "Dependent child aged 14 or over (HBAI equivalence scale)" + documentation = ( + "A dependent child at or above the older-child age of the HBAI " + "modified OECD equivalence scale." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#equivalisation-1" + + def formula(person, period, parameters): + threshold = parameters(period).household.demographic.equiv.child_age_threshold + return person("is_hbai_dependent_child", period) & ( + person("age", period) >= threshold + ) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_under_14.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_under_14.py new file mode 100644 index 0000000000..7595f85ead --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_child_under_14.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class is_hbai_child_under_14(Variable): + value_type = bool + entity = Person + label = "Dependent child under 14 (HBAI equivalence scale)" + documentation = ( + "A dependent child below the older-child age of the HBAI modified " + "OECD equivalence scale." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#equivalisation-1" + + def formula(person, period, parameters): + threshold = parameters(period).household.demographic.equiv.child_age_threshold + return person("is_hbai_dependent_child", period) & ( + person("age", period) < threshold + ) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py new file mode 100644 index 0000000000..0c1d587c88 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py @@ -0,0 +1,57 @@ +from policyengine_uk.model_api import * + + +class is_hbai_dependent_child(Variable): + """Dependent child as defined for Households Below Average Income (HBAI). + + HBAI (and the Family Resources Survey, whose benefit units it uses) + counts a person as a dependent child if they are under 16, or aged 16 + to 19 and not married, in a civil partnership or living with a partner, + living with parents or a responsible adult, and in full-time + non-advanced education or unwaged government training. + + Datasets built from the FRS should supply this directly: it is exactly + the FRS child-table record. The formula is a calculator fallback that + infers the definition from benefit-unit structure: + + - everyone under 16 is a dependent child; + - a 16- or 17-year-old who is neither the benefit-unit head nor an + identified parent is treated as a dependent child. This keeps the + previous age-18 treatment: the FRS puts non-dependent 16- and + 17-year-olds in their own benefit unit, so a non-head in that age + group is normally an FRS dependent child, whatever the education + inputs say; + - an 18- or 19-year-old who is neither the head nor an identified parent + is a dependent child only if they are in non-advanced education or + approved training and the benefit unit contains an identified parent + (`is_parent`). Without an identified parent the model cannot tell a + dependant from a partner, so it keeps treating them as an adult. + + This is a statistical definition. Benefit and tax rules use each + programme's own legal definition of a child. + """ + + value_type = bool + entity = Person + label = "Dependent child (HBAI definition)" + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#child" + + def formula(person, period, parameters): + p = parameters(period).household.demographic.hbai.dependent_child + age = person("age", period) + is_parent = person("is_parent", period) + lives_as_dependant = ~person("is_benunit_head", period) & ~is_parent + in_education_or_training = person( + "is_in_non_advanced_education", period + ) | person("is_in_approved_training", period) + lives_with_identified_parent = person.benunit.any(is_parent) + under_child_age = age < p.age_limit + under_18_dependant = lives_as_dependant & person("age_under_18", period) + young_person = ( + lives_as_dependant + & (age < p.young_person_age_limit) + & in_education_or_training + & lives_with_identified_parent + ) + return under_child_age | under_18_dependant | young_person diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_pensioner.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_pensioner.py new file mode 100644 index 0000000000..802eb4c2e1 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_pensioner.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class is_hbai_pensioner(Variable): + value_type = bool + entity = Person + label = "Pensioner (HBAI definition)" + documentation = ( + "Households Below Average Income defines pensioners as all adults at " + "or above State Pension age, classified individually." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#pensioner" + + def formula(person, period, parameters): + return person("is_hbai_adult", period) & person("is_SP_age", period) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_working_age_adult.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_working_age_adult.py new file mode 100644 index 0000000000..1d06fcf0cb --- /dev/null +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_working_age_adult.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class is_hbai_working_age_adult(Variable): + value_type = bool + entity = Person + label = "Working-age adult (HBAI definition)" + documentation = ( + "Households Below Average Income defines working-age adults as all " + "adults below State Pension age." + ) + definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#working-age-adults" + + def formula(person, period, parameters): + return person("is_hbai_adult", period) & ~person("is_SP_age", period) diff --git a/policyengine_uk/variables/household/demographic/is_WA_adult.py b/policyengine_uk/variables/household/demographic/is_WA_adult.py index 8db33a6dbe..04004d1ad0 100644 --- a/policyengine_uk/variables/household/demographic/is_WA_adult.py +++ b/policyengine_uk/variables/household/demographic/is_WA_adult.py @@ -5,7 +5,12 @@ class is_WA_adult(Variable): value_type = bool entity = Person - label = "Whether is a working-age adult" + label = "Aged 18 to below State Pension age (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use is_hbai_working_age_adult for HBAI statistics, or the programme's own age conditions instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/is_adult.py b/policyengine_uk/variables/household/demographic/is_adult.py index 29bb92d537..cd0e67cb95 100644 --- a/policyengine_uk/variables/household/demographic/is_adult.py +++ b/policyengine_uk/variables/household/demographic/is_adult.py @@ -5,7 +5,12 @@ class is_adult(Variable): value_type = bool entity = Person - label = "Whether this person is an adult" + label = "Aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the negation of age_under_18 for the age band, is_hbai_adult for HBAI statistics, or is_claimant_or_partner for the claimant and partner of a benefit unit instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/is_benunit_eldest_child.py b/policyengine_uk/variables/household/demographic/is_benunit_eldest_child.py index 58e883aabe..b4589cf46e 100644 --- a/policyengine_uk/variables/household/demographic/is_benunit_eldest_child.py +++ b/policyengine_uk/variables/household/demographic/is_benunit_eldest_child.py @@ -5,7 +5,12 @@ class is_benunit_eldest_child(Variable): value_type = bool entity = Person - label = "Eldest child in the benefit unit" + label = "Eldest benefit-unit member under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the programme's own child index instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/is_child.py b/policyengine_uk/variables/household/demographic/is_child.py index 675c320d2e..8f0dad7018 100644 --- a/policyengine_uk/variables/household/demographic/is_child.py +++ b/policyengine_uk/variables/household/demographic/is_child.py @@ -5,7 +5,12 @@ class is_child(Variable): value_type = bool entity = Person - label = "Is a child" + label = "Aged under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use age_under_18 for the age band, is_hbai_dependent_child for HBAI statistics, or the programme's own child definition (for example is_child_or_qualifying_young_person_for_universal_credit) instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py new file mode 100644 index 0000000000..7f680bc04d --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class is_claimant_or_partner(Variable): + """The single adult or couple a benefit unit is formed around. + + In benefit law this is the claimant and their partner, as opposed to the + children and young persons they are responsible for (SSCBA 1992 + s.137(1) "family"; WRA 2012 ss.39-40; SPCA 2002 s.17; TCA 2002 s.3). + The same two people are the spouses or civil partners for income tax + purposes when the benefit unit is a married couple. + + Benefit units follow the Family Resources Survey definition: a single + adult or a couple plus any dependent children. So the claimant and + partner are the benefit unit's adults under that definition, which is + the HBAI one (`is_hbai_adult`). Each programme applies its own age and + other conditions to them. + """ + + value_type = bool + entity = Person + label = "Claimant or partner" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + "https://www.legislation.gov.uk/ukpga/2012/5/section/39", + "https://www.legislation.gov.uk/ukpga/2002/16/section/17", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ) + + def formula(person, period, parameters): + return person("is_hbai_adult", period) diff --git a/policyengine_uk/variables/household/demographic/is_eldest_child.py b/policyengine_uk/variables/household/demographic/is_eldest_child.py index 91a13a0e0b..0ce0204001 100644 --- a/policyengine_uk/variables/household/demographic/is_eldest_child.py +++ b/policyengine_uk/variables/household/demographic/is_eldest_child.py @@ -3,8 +3,12 @@ class is_eldest_child(Variable): - label = "Is the eldest child" - documentation = "Whether this person is the eldest child in the benefit unit" + label = "Eldest benefit-unit member under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the programme's own child index instead." + ) entity = Person definition_period = YEAR value_type = bool diff --git a/policyengine_uk/variables/household/demographic/is_married.py b/policyengine_uk/variables/household/demographic/is_married.py index 6f234b4ae1..7559be4bc7 100644 --- a/policyengine_uk/variables/household/demographic/is_married.py +++ b/policyengine_uk/variables/household/demographic/is_married.py @@ -5,8 +5,13 @@ class is_married(Variable): value_type = bool entity = BenUnit label = "Married" - documentation = "Whether the benefit unit adults are married to each other or in a civil partnership" + documentation = ( + "Whether the benefit unit's couple are married to each other or in a " + "civil partnership. Datasets supply this from the survey. Without " + "that input, the calculator presumes every couple is married, since " + "marriage cannot be told apart from cohabitation." + ) definition_period = YEAR def formula(benunit, period, parameters): - return add(benunit, period, ["is_adult"]) == 2 + return benunit("is_couple", period) diff --git a/policyengine_uk/variables/household/demographic/is_older_child.py b/policyengine_uk/variables/household/demographic/is_older_child.py index a7555133fd..e0e142f233 100644 --- a/policyengine_uk/variables/household/demographic/is_older_child.py +++ b/policyengine_uk/variables/household/demographic/is_older_child.py @@ -5,7 +5,12 @@ class is_older_child(Variable): value_type = bool entity = Person - label = "Whether the person is over 14 but under 18" + label = "Aged 14 to 17 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use is_hbai_child_aged_14_or_over instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/is_young_child.py b/policyengine_uk/variables/household/demographic/is_young_child.py index a4708d441f..c7d3d5962e 100644 --- a/policyengine_uk/variables/household/demographic/is_young_child.py +++ b/policyengine_uk/variables/household/demographic/is_young_child.py @@ -5,7 +5,12 @@ class is_young_child(Variable): value_type = bool entity = Person - label = "Whether the person is under 14" + label = "Aged under 14 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use is_hbai_child_under_14 instead." + ) definition_period = YEAR def formula(person, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/marital_status.py b/policyengine_uk/variables/household/demographic/marital_status.py index e70c06fafa..d50ad9bc3b 100644 --- a/policyengine_uk/variables/household/demographic/marital_status.py +++ b/policyengine_uk/variables/household/demographic/marital_status.py @@ -16,11 +16,17 @@ class marital_status(Variable): default_value = MaritalStatus.SINGLE entity = Person label = "Marital status" + documentation = ( + "Datasets supply this from the survey. Without that input, the " + "claimant and partner of a married benefit unit are married and " + "everyone else, including their children, is single." + ) definition_period = YEAR def formula(person, period, parameters): return where( - person.benunit("is_married", period), + person.benunit("is_married", period) + & person("is_claimant_or_partner", period), MaritalStatus.MARRIED, MaritalStatus.SINGLE, ) diff --git a/policyengine_uk/variables/household/demographic/num_adults.py b/policyengine_uk/variables/household/demographic/num_adults.py index 5697db5cb0..945a193dbb 100644 --- a/policyengine_uk/variables/household/demographic/num_adults.py +++ b/policyengine_uk/variables/household/demographic/num_adults.py @@ -4,7 +4,12 @@ class num_adults(Variable): value_type = int entity = BenUnit - label = "The number of adults in the family" + label = "Members aged 18 or over in the benefit unit (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count of is_claimant_or_partner, or of is_hbai_adult for HBAI statistics instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_children.py b/policyengine_uk/variables/household/demographic/num_children.py index b9e33ef214..e8175fe7db 100644 --- a/policyengine_uk/variables/household/demographic/num_children.py +++ b/policyengine_uk/variables/household/demographic/num_children.py @@ -4,7 +4,12 @@ class num_children(Variable): value_type = int entity = BenUnit - label = "The number of children in the family" + label = "Members aged under 18 in the benefit unit (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count of the programme's own child definition, or of is_hbai_dependent_child for HBAI statistics instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_disabled_adults.py b/policyengine_uk/variables/household/demographic/num_disabled_adults.py index 0bf0731b02..c83e14eb90 100644 --- a/policyengine_uk/variables/household/demographic/num_disabled_adults.py +++ b/policyengine_uk/variables/household/demographic/num_disabled_adults.py @@ -4,7 +4,12 @@ class num_disabled_adults(Variable): value_type = int entity = BenUnit - label = "Number of disabled adults" + label = "Disabled benefit-unit members aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over is_claimant_or_partner instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_disabled_children.py b/policyengine_uk/variables/household/demographic/num_disabled_children.py index 5d6fcd8c05..376215ea07 100644 --- a/policyengine_uk/variables/household/demographic/num_disabled_children.py +++ b/policyengine_uk/variables/household/demographic/num_disabled_children.py @@ -4,7 +4,12 @@ class num_disabled_children(Variable): value_type = int entity = BenUnit - label = "Number of disabled children" + label = "Disabled benefit-unit members under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over the programme's own child definition instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_enhanced_disabled_adults.py b/policyengine_uk/variables/household/demographic/num_enhanced_disabled_adults.py index 8dc90fe17a..71a26bee50 100644 --- a/policyengine_uk/variables/household/demographic/num_enhanced_disabled_adults.py +++ b/policyengine_uk/variables/household/demographic/num_enhanced_disabled_adults.py @@ -4,7 +4,12 @@ class num_enhanced_disabled_adults(Variable): value_type = int entity = BenUnit - label = "Number of enhanced disabled adults" + label = "Enhanced-disabled benefit-unit members aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over is_claimant_or_partner instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_enhanced_disabled_children.py b/policyengine_uk/variables/household/demographic/num_enhanced_disabled_children.py index 3ef0002725..c5bc8e454e 100644 --- a/policyengine_uk/variables/household/demographic/num_enhanced_disabled_children.py +++ b/policyengine_uk/variables/household/demographic/num_enhanced_disabled_children.py @@ -4,7 +4,12 @@ class num_enhanced_disabled_children(Variable): value_type = int entity = BenUnit - label = "Number of enhanced disabled children" + label = "Enhanced-disabled benefit-unit members under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over the programme's own child definition instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_severely_disabled_adults.py b/policyengine_uk/variables/household/demographic/num_severely_disabled_adults.py index fef66b4f0a..b574c01285 100644 --- a/policyengine_uk/variables/household/demographic/num_severely_disabled_adults.py +++ b/policyengine_uk/variables/household/demographic/num_severely_disabled_adults.py @@ -4,7 +4,12 @@ class num_severely_disabled_adults(Variable): value_type = int entity = BenUnit - label = "Number of severely disabled adults" + label = "Severely disabled benefit-unit members aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over is_claimant_or_partner instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/num_severely_disabled_children.py b/policyengine_uk/variables/household/demographic/num_severely_disabled_children.py index ce4abd9d52..1be5bd7934 100644 --- a/policyengine_uk/variables/household/demographic/num_severely_disabled_children.py +++ b/policyengine_uk/variables/household/demographic/num_severely_disabled_children.py @@ -4,7 +4,12 @@ class num_severely_disabled_children(Variable): value_type = int entity = BenUnit - label = "Number of severely disabled children" + label = "Severely disabled benefit-unit members under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use a count over the programme's own child definition instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/relation_type.py b/policyengine_uk/variables/household/demographic/relation_type.py index ae32d6c625..9bdb157b32 100644 --- a/policyengine_uk/variables/household/demographic/relation_type.py +++ b/policyengine_uk/variables/household/demographic/relation_type.py @@ -12,11 +12,21 @@ class relation_type(Variable): default_value = RelationType.SINGLE possible_values = RelationType label = "Whether single or a couple" + documentation = ( + "Whether the claimant has a partner. A couple is two people who are " + "married or civil partners in the same household, or who live " + "together as if they were; the same definition applies across " + "benefit and tax credit law. Dependent children and young persons " + "are never the partner." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + "https://www.legislation.gov.uk/ukpga/2012/5/section/39", + "https://www.legislation.gov.uk/ukpga/2002/16/section/17", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ) def formula(benunit, period, parameters): - return where( - benunit.sum(benunit.members("is_adult", period)) == 1, - RelationType.SINGLE, - RelationType.COUPLE, - ) + claimants = add(benunit, period, ["is_claimant_or_partner"]) + return where(claimants >= 2, RelationType.COUPLE, RelationType.SINGLE) diff --git a/policyengine_uk/variables/household/demographic/youngest_adult_age.py b/policyengine_uk/variables/household/demographic/youngest_adult_age.py index 0f51550fd0..caa332365e 100644 --- a/policyengine_uk/variables/household/demographic/youngest_adult_age.py +++ b/policyengine_uk/variables/household/demographic/youngest_adult_age.py @@ -4,7 +4,12 @@ class youngest_adult_age(Variable): value_type = float entity = BenUnit - label = "Eldest adult age" + label = "Age of the youngest benefit-unit member aged 18 or over (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use youngest_claimant_or_partner_age instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/demographic/youngest_child_age.py b/policyengine_uk/variables/household/demographic/youngest_child_age.py index 32406e7b5f..83ae608e62 100644 --- a/policyengine_uk/variables/household/demographic/youngest_child_age.py +++ b/policyengine_uk/variables/household/demographic/youngest_child_age.py @@ -4,7 +4,12 @@ class youngest_child_age(Variable): value_type = float entity = BenUnit - label = "Eldest adult age" + label = "Age of the youngest benefit-unit member under 18 (deprecated)" + documentation = ( + "Deprecated: an age cut-off with no legal basis, kept only for " + "downstream compatibility with its original formula. Nothing in " + "policyengine-uk uses it. Use the programme's own child definition instead." + ) definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/income/is_couple.py b/policyengine_uk/variables/household/income/is_couple.py index 236a1da10c..e8b8bc7ea1 100644 --- a/policyengine_uk/variables/household/income/is_couple.py +++ b/policyengine_uk/variables/household/income/is_couple.py @@ -4,7 +4,8 @@ class is_couple(Variable): value_type = bool entity = BenUnit - label = "Whether this benefit unit contains a joint couple claimant for benefits" + label = "Claimant has a partner" + documentation = "Whether the claimant is a member of a couple." definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/income/is_lone_parent.py b/policyengine_uk/variables/household/income/is_lone_parent.py index 07ffdcb0b3..613e885de6 100644 --- a/policyengine_uk/variables/household/income/is_lone_parent.py +++ b/policyengine_uk/variables/household/income/is_lone_parent.py @@ -4,10 +4,20 @@ class is_lone_parent(Variable): value_type = bool entity = BenUnit - label = "Whether the family is a lone parent family" + label = "Lone parent" + documentation = ( + "A claimant with no partner who is responsible for a child or young " + "person in the same household, as defined for Income Support, " + "Housing Benefit and council tax reduction." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + ) def formula(benunit, period, parameters): - family_type = benunit("family_type", period) - families = family_type.possible_values - return family_type == families.LONE_PARENT + return benunit("is_single", period) & benunit( + "is_responsible_for_child_or_young_person_for_legacy_benefits", period + ) diff --git a/policyengine_uk/variables/household/income/is_single.py b/policyengine_uk/variables/household/income/is_single.py index b226945fe7..db2a623ef6 100644 --- a/policyengine_uk/variables/household/income/is_single.py +++ b/policyengine_uk/variables/household/income/is_single.py @@ -4,7 +4,8 @@ class is_single(Variable): value_type = bool entity = BenUnit - label = "Whether this benefit unit contains a single claimant for benefits" + label = "Claimant has no partner" + documentation = "Whether the claimant is not a member of a couple." definition_period = YEAR def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/household/income/is_single_person.py b/policyengine_uk/variables/household/income/is_single_person.py index b8da33b4e9..c17437f4af 100644 --- a/policyengine_uk/variables/household/income/is_single_person.py +++ b/policyengine_uk/variables/household/income/is_single_person.py @@ -4,10 +4,15 @@ class is_single_person(Variable): value_type = bool entity = BenUnit - label = "Whether the family is a single person" + label = "Single claimant" + documentation = ( + "A claimant who has no partner and is not a lone parent, as defined " + "for Housing Benefit and council tax reduction." + ) definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/2" def formula(benunit, period, parameters): - family_type = benunit("family_type", period) - families = family_type.possible_values - return family_type == families.SINGLE + return benunit("is_single", period) & ~benunit( + "is_responsible_for_child_or_young_person_for_legacy_benefits", period + ) From 0f53b3941cda3fd2d309052ec0151c30394de0b5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:13:38 -0400 Subject: [PATCH 02/24] Test the benefit-unit structure and HBAI population groups Co-Authored-By: Claude Opus 5.5 --- .../demographic/benefit_unit_structure.yaml | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml new file mode 100644 index 0000000000..7c59bc0cc8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -0,0 +1,239 @@ +# Benefit-unit structure: the claimant and partner, couples, and the HBAI +# population groups. +# +# HBAI FYE 2025 glossary: a dependent child is under 16, or 16-19 and not +# partnered, living with parents or a responsible adult, and in full-time +# non-advanced education or unwaged government training. Everyone else is an +# adult; pensioners are adults at or above State Pension age (66 in 2025). +# Benefit units are a single adult or a couple plus dependent children, so the +# claimant and partner are the benefit unit's HBAI adults. + +- name: Lone parent with an 18-year-old at school is a lone parent, not a couple + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + child: + age: 10 + benunits: + benunit: + members: [parent, student, child] + households: + household: + members: [parent, student, child] + output: + is_hbai_dependent_child: [false, true, true] + is_claimant_or_partner: [true, false, false] + hbai_person_type: [WORKING_AGE_ADULT, CHILD, CHILD] + relation_type: SINGLE + is_couple: false + is_lone_parent: true + is_single_person: false + is_married: false + marital_status: [SINGLE, SINGLE, SINGLE] + eldest_claimant_or_partner_age: 40 + youngest_claimant_or_partner_age: 40 + # The deprecated shims keep their age-18 answers. + is_adult: [true, true, false] + family_type: COUPLE_WITH_CHILDREN + +- name: Without an identified parent an 18-year-old at school stays an adult + # "Living with parents" needs an identified parent; otherwise the model + # cannot tell a dependant from a partner and keeps the adult treatment. + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 24 + other: + age: 19 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [head, other] + households: + household: + members: [head, other] + output: + is_hbai_dependent_child: [false, false] + is_claimant_or_partner: [true, true] + relation_type: COUPLE + +- name: A 17-year-old living alone is a single adult, not their own child + period: 2025 + absolute_error_margin: 0 + input: + people: + young_person: + age: 17 + benunits: + benunit: + members: [young_person] + households: + household: + members: [young_person] + output: + is_hbai_dependent_child: false + is_hbai_adult: true + is_claimant_or_partner: true + hbai_person_type: WORKING_AGE_ADULT + relation_type: SINGLE + is_couple: false + is_single_person: true + is_lone_parent: false + # Deprecated shims: no one aged 18+, and the claimant counts as a child. + num_adults: 0 + family_type: LONE_PARENT + +- name: A 17-year-old lone parent with a baby + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 17 + is_parent: true + baby: + age: 0 + benunits: + benunit: + members: [parent, baby] + households: + household: + members: [parent, baby] + output: + is_claimant_or_partner: [true, false] + is_lone_parent: true + is_couple: false + youngest_claimant_or_partner_age: 17 + +- name: A couple with an 18-year-old at school stays a couple with a young person + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 40 + is_parent: true + parent_2: + age: 40 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent_1, parent_2, student] + households: + household: + members: [parent_1, parent_2, student] + output: + is_claimant_or_partner: [true, true, false] + relation_type: COUPLE + is_couple: true + is_single_person: false + is_lone_parent: false + is_married: true + marital_status: [MARRIED, MARRIED, SINGLE] + # Deprecated shim: three members aged 18+ are not "two adults". + family_type: SINGLE + +- name: A 16- or 17-year-old non-head, non-parent keeps the dependant treatment + # Flagged assumption: the FRS puts non-dependent 16- and 17-year-olds in their + # own benefit unit, so a non-head in that age group is treated as a dependant + # whatever their education inputs. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + teenager: + age: 17 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, teenager] + households: + household: + members: [parent, teenager] + output: + is_hbai_dependent_child: [false, true] + is_claimant_or_partner: [true, false] + +- name: A 19-year-old in higher education is an adult + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + is_parent: true + student: + age: 19 + current_education: TERTIARY + benunits: + benunit: + members: [parent, student] + households: + household: + members: [parent, student] + output: + is_hbai_dependent_child: [false, false] + hbai_person_type: [WORKING_AGE_ADULT, WORKING_AGE_ADULT] + +- name: HBAI pensioners are adults at or above State Pension age + period: 2025 + absolute_error_margin: 0 + input: + people: + pensioner: + age: 70 + is_parent: true + young_pensioner: + age: 66 + grandchild: + age: 18 + current_education: POST_SECONDARY + benunits: + benunit: + members: [pensioner, young_pensioner, grandchild] + households: + household: + members: [pensioner, young_pensioner, grandchild] + output: + is_hbai_pensioner: [true, true, false] + is_hbai_working_age_adult: [false, false, false] + hbai_person_type: [PENSIONER, PENSIONER, CHILD] + +- name: HBAI equivalence-scale child bands + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + child_13: + age: 13 + child_14: + age: 14 + student_18: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent, child_13, child_14, student_18] + households: + household: + members: [parent, child_13, child_14, student_18] + output: + is_hbai_child_under_14: [false, true, false, false] + is_hbai_child_aged_14_or_over: [false, false, true, true] From 04e85a14d2a5c5b7b072b128781f36136740abe7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:16:41 -0400 Subject: [PATCH 03/24] Use HBAI adults and children in equivalisation; make age-18 splits explicit in modelling code The HBAI modified OECD scale weights HBAI adults and dependent children, not an age-18 split; results differ only in households with no one aged 18 or over. The private school imputation and the labour supply elasticity groups keep an explicit age band (no law governs them), with identical results. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/dynamics/demographics.py | 43 +++++++++ policyengine_uk/dynamics/participation.py | 14 ++- policyengine_uk/dynamics/progression.py | 11 ++- .../two_child_limit_payment.yaml | 75 --------------- .../household/demographic/equivalisation.yaml | 94 +++++++++++++++++++ .../tests/test_dynamics_demographics.py | 82 ++++++++++++++++ .../contrib/labour/attends_private_school.py | 6 +- .../contrib/labour/private_school_vat.py | 4 +- .../household_equivalisation_ahc.py | 13 ++- .../household_equivalisation_bhc.py | 13 ++- 10 files changed, 261 insertions(+), 94 deletions(-) create mode 100644 policyengine_uk/dynamics/demographics.py create mode 100644 policyengine_uk/tests/test_dynamics_demographics.py diff --git a/policyengine_uk/dynamics/demographics.py b/policyengine_uk/dynamics/demographics.py new file mode 100644 index 0000000000..8bbaca459f --- /dev/null +++ b/policyengine_uk/dynamics/demographics.py @@ -0,0 +1,43 @@ +"""Benefit-unit composition for the labour supply elasticity groups. + +The OBR labour supply note (Tables A1 and A2) assigns elasticities by +partnership and by the presence and age of the youngest child, without +defining a child. The dynamics modules keep an explicit age band for this: +benefit-unit members under 18 are children and members aged 18 or over are +adults. This is a modelling choice for the elasticity groups, not a legal +definition. + +Reference: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf +""" + +import numpy as np +import pandas as pd + + +def benunit_age_18_composition(sim) -> pd.DataFrame: + """Per-person view of their benefit unit, split at age 18. + + Returns a DataFrame aligned with the person entity, with columns: + - count_under_18: benefit-unit members under 18; + - youngest_under_18_age: age of the youngest such member (inf if none); + - count_aged_18_or_over: benefit-unit members aged 18 or over. + """ + age = np.asarray(sim.calculate("age"), dtype=float) + # Position of each person's benefit unit, from the simulation's own entity + # structure (works for datasets and hand-built situations alike). + benunit_id = np.asarray(sim.populations["benunit"].members_entity_id) + under_18 = age < 18 + people = pd.DataFrame( + { + "benunit_id": benunit_id, + "under_18": under_18, + "aged_18_or_over": ~under_18, + "age_if_under_18": np.where(under_18, age, np.inf), + } + ) + by_benunit = people.groupby("benunit_id").agg( + count_under_18=("under_18", "sum"), + youngest_under_18_age=("age_if_under_18", "min"), + count_aged_18_or_over=("aged_18_or_over", "sum"), + ) + return by_benunit.reindex(benunit_id).reset_index(drop=True) diff --git a/policyengine_uk/dynamics/participation.py b/policyengine_uk/dynamics/participation.py index d7f4c1672e..728bd4704c 100644 --- a/policyengine_uk/dynamics/participation.py +++ b/policyengine_uk/dynamics/participation.py @@ -12,6 +12,7 @@ from policyengine_uk import Simulation import warnings +from policyengine_uk.dynamics.demographics import benunit_age_18_composition from policyengine_uk.model_api import WEEKS_IN_YEAR # Weekly hours treated as full time when putting non-workers on a comparable @@ -38,13 +39,14 @@ def calculate_participation_elasticities( # Get demographic characteristics gender = sim.calculate("gender") is_married = sim.calculate("is_married", map_to="person") - has_children = sim.calculate("benunit_count_children", map_to="person") > 0 - youngest_child_age = sim.calculate("youngest_child_age", map_to="person") + composition = benunit_age_18_composition(sim) + has_children = composition["count_under_18"].values > 0 + youngest_child_age = composition["youngest_under_18_age"].values is_single = ~is_married # Get partner employment status for married individuals is_household_head = sim.calculate("is_household_head", map_to="person") - benunit_count_adults = sim.calculate("benunit_count_adults", map_to="person") + benunit_count_adults = composition["count_aged_18_or_over"].values employment_income = sim.calculate("employment_income") benunit_id = sim.calculate("benunit_id", map_to="person") adult_index = sim.calculate("adult_index") @@ -53,14 +55,16 @@ def calculate_participation_elasticities( df = pd.DataFrame( { "benunit_id": benunit_id, - "is_adult": adult_index > 0, + "is_aged_18_or_over": adult_index > 0, "employed": employment_income > 0, "benunit_count_adults": benunit_count_adults, } ) # Calculate total employed adults per benunit - benunit_employed = df[df["is_adult"]].groupby("benunit_id")["employed"].sum() + benunit_employed = ( + df[df["is_aged_18_or_over"]].groupby("benunit_id")["employed"].sum() + ) # Map back to individuals employed_adults_in_benunit = df["benunit_id"].map(benunit_employed).fillna(0) diff --git a/policyengine_uk/dynamics/progression.py b/policyengine_uk/dynamics/progression.py index 47cbbc3b15..b17ff0b02c 100644 --- a/policyengine_uk/dynamics/progression.py +++ b/policyengine_uk/dynamics/progression.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd from policyengine_uk import Simulation +from policyengine_uk.dynamics.demographics import benunit_age_18_composition def calculate_derivative( @@ -203,8 +204,9 @@ def calculate_labour_substitution_elasticities( # Get demographic characteristics for elasticity assignment gender = sim.calculate("gender") is_married = sim.calculate("is_married", map_to="person") - has_children = sim.calculate("benunit_count_children", map_to="person") > 0 - youngest_child_age = sim.calculate("youngest_child_age", map_to="person") + composition = benunit_age_18_composition(sim) + has_children = composition["count_under_18"].values > 0 + youngest_child_age = composition["youngest_under_18_age"].values # Initialize elasticity array elasticities = np.zeros(gender.shape, dtype=float) @@ -273,8 +275,9 @@ def calculate_labour_net_income_elasticities( # Get demographic characteristics for elasticity assignment gender = sim.calculate("gender") is_married = sim.calculate("is_married", map_to="person") - has_children = sim.calculate("benunit_count_children", map_to="person") > 0 - youngest_child_age = sim.calculate("youngest_child_age", map_to="person") + composition = benunit_age_18_composition(sim) + has_children = composition["count_under_18"].values > 0 + youngest_child_age = composition["youngest_under_18_age"].values # Initialize elasticity array elasticities = np.zeros(gender.shape, dtype=float) diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml index 4591c64aca..205ccf6291 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml @@ -8,21 +8,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -42,21 +35,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -76,26 +62,17 @@ people: parent: age: 30 - is_child: false child_1: age: 8 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 6 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 4 - is_child: true - child_index: 3 uc_is_child_limit_affected: true child_4: age: 2 - is_child: true - child_index: 4 uc_is_child_limit_affected: true benunits: benunit: @@ -115,31 +92,20 @@ people: parent: age: 35 - is_child: false child_1: age: 12 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 10 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 7 - is_child: true - child_index: 3 uc_is_child_limit_affected: true child_4: age: 5 - is_child: true - child_index: 4 uc_is_child_limit_affected: true child_5: age: 3 - is_child: true - child_index: 5 uc_is_child_limit_affected: true benunits: benunit: @@ -159,16 +125,11 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false benunits: benunit: @@ -187,21 +148,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -220,21 +174,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -253,21 +200,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -286,24 +226,16 @@ people: parent_1: age: 35 - is_child: false parent_2: age: 33 - is_child: false child_1: age: 8 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 5 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 2 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: @@ -322,21 +254,14 @@ people: parent: age: 30 - is_child: false child_1: age: 5 - is_child: true - child_index: 1 uc_is_child_limit_affected: false child_2: age: 3 - is_child: true - child_index: 2 uc_is_child_limit_affected: false child_3: age: 1 - is_child: true - child_index: 3 uc_is_child_limit_affected: true benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/equivalisation.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/equivalisation.yaml index 57f2d881cd..656d990f9e 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/equivalisation.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/equivalisation.yaml @@ -119,3 +119,97 @@ output: household_equivalisation_bhc: 1.07 household_equivalisation_ahc: 0.98 + +# HBAI classifies people as adults or dependent children (HBAI FYE 2025 +# glossary), not by an age-18 cut-off. The two differ numerically only in +# households with no one aged 18 or over, because the scale gives other adults +# and children aged 14+ the same weight (0.33 BHC, 0.42 AHC). + +- name: Equivalisation factor — 17-year-old living alone is a single adult + # First adult only: 0.67 BHC, 0.58 AHC (previously 0.67 + 0.33 for a + # "child aged 14+", with no adult). + period: 2025 + absolute_error_margin: 0.001 + input: + people: + young_person: + age: 17 + benunits: + benunit: + members: [young_person] + households: + household: + members: [young_person] + output: + household_equivalisation_bhc: 0.67 + household_equivalisation_ahc: 0.58 + +- name: Equivalisation factor — 17-year-old lone parent with a baby + # BHC 0.67 + 0.20 = 0.87; AHC 0.58 + 0.20 = 0.78. + period: 2025 + absolute_error_margin: 0.001 + input: + people: + parent: + age: 17 + is_parent: true + baby: + age: 0 + benunits: + benunit: + members: [parent, baby] + households: + household: + members: [parent, baby] + output: + household_equivalisation_bhc: 0.87 + household_equivalisation_ahc: 0.78 + +- name: Equivalisation factor — two 17-year-olds sharing a household + # Each heads their own benefit unit, so both are adults: BHC 0.67 + 0.33 = + # 1.00; AHC 0.58 + 0.42 = 1.00. + period: 2025 + absolute_error_margin: 0.001 + input: + people: + sharer_1: + age: 17 + sharer_2: + age: 17 + benunits: + benunit_1: + members: [sharer_1] + benunit_2: + members: [sharer_2] + households: + household: + members: [sharer_1, sharer_2] + output: + household_equivalisation_bhc: 1.00 + household_equivalisation_ahc: 1.00 + +- name: Equivalisation factor — couple with an 18-year-old dependant at school + # The 18-year-old is an HBAI dependent child aged 14+: BHC 0.67 + 0.33 + 0.33 + # = 1.33; AHC 0.58 + 0.42 + 0.42 = 1.42 (unchanged from the age-18 split). + period: 2025 + absolute_error_margin: 0.001 + input: + people: + parent_1: + age: 45 + is_parent: true + parent_2: + age: 45 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent_1, parent_2, student] + households: + household: + members: [parent_1, parent_2, student] + output: + household_equivalisation_bhc: 1.33 + household_equivalisation_ahc: 1.42 diff --git a/policyengine_uk/tests/test_dynamics_demographics.py b/policyengine_uk/tests/test_dynamics_demographics.py new file mode 100644 index 0000000000..56f85efb0f --- /dev/null +++ b/policyengine_uk/tests/test_dynamics_demographics.py @@ -0,0 +1,82 @@ +"""The labour supply dynamics split benefit units at age 18 explicitly. + +The OBR elasticity groups do not define a child, so the dynamics modules keep +an explicit age band instead of reading the deprecated generic child and adult +variables. This checks the helper reproduces those variables exactly. +""" + +import numpy as np + +from policyengine_uk import Simulation +from policyengine_uk.dynamics.demographics import benunit_age_18_composition + +SITUATION = { + "people": { + "parent": {"age": {"2025": 40}}, + "student": {"age": {"2025": 18}}, + "child_1": {"age": {"2025": 10}}, + "child_2": {"age": {"2025": 3}}, + "lodger": {"age": {"2025": 30}}, + "teen_head": {"age": {"2025": 17}}, + }, + "benunits": { + "family": {"members": ["parent", "student", "child_1", "child_2"]}, + "lodger": {"members": ["lodger"]}, + "teen": {"members": ["teen_head"]}, + }, + "households": { + "household": { + "members": [ + "parent", + "student", + "child_1", + "child_2", + "lodger", + "teen_head", + ] + } + }, +} + + +class _DefaultPeriod: + """Simulation.calculate with a default period, as the dynamics call it.""" + + def __init__(self, sim, period): + self.sim, self.period = sim, period + self.populations = sim.populations + + def calculate(self, variable, period=None, **kwargs): + return self.sim.calculate(variable, period or self.period, **kwargs) + + +def test_composition_matches_the_deprecated_age_18_variables(): + sim = Simulation(situation=SITUATION) + composition = benunit_age_18_composition(_DefaultPeriod(sim, 2025)) + np.testing.assert_array_equal( + composition["count_under_18"].values, + sim.calculate("benunit_count_children", 2025, map_to="person"), + ) + np.testing.assert_array_equal( + composition["youngest_under_18_age"].values, + sim.calculate("youngest_child_age", 2025, map_to="person"), + ) + np.testing.assert_array_equal( + composition["count_aged_18_or_over"].values, + sim.calculate("benunit_count_adults", 2025, map_to="person"), + ) + + +def test_composition_values(): + sim = Simulation(situation=SITUATION) + composition = benunit_age_18_composition(_DefaultPeriod(sim, 2025)) + assert composition["count_under_18"].tolist() == [2, 2, 2, 2, 0, 1] + assert composition["youngest_under_18_age"].tolist() == [ + 3, + 3, + 3, + 3, + np.inf, + 17, + ] + assert composition["count_aged_18_or_over"].tolist() == [2, 2, 2, 2, 1, 0] diff --git a/policyengine_uk/variables/contrib/labour/attends_private_school.py b/policyengine_uk/variables/contrib/labour/attends_private_school.py index 27cc135f61..6bd463ec02 100644 --- a/policyengine_uk/variables/contrib/labour/attends_private_school.py +++ b/policyengine_uk/variables/contrib/labour/attends_private_school.py @@ -48,7 +48,9 @@ def formula(person, period, parameters): person = household.members - is_child = person("is_child", period) + # No law governs this imputation. The attendance rates apply to + # everyone under 18, so the pool is an explicit age band. + under_18 = person("age_under_18", period) taxes = household.sum( person("income_tax", period) + person("national_insurance", period) @@ -90,7 +92,7 @@ def formula(person, period, parameters): p_attends_private_school = ( _rate_by_percentile[percentile] * STUDENT_POPULATION_ADJUSTMENT_FACTOR - * is_child + * under_18 ) # Use pre-generated random draw from dataset instead of calling random() diff --git a/policyengine_uk/variables/contrib/labour/private_school_vat.py b/policyengine_uk/variables/contrib/labour/private_school_vat.py index 33b641d450..d6e7823eec 100644 --- a/policyengine_uk/variables/contrib/labour/private_school_vat.py +++ b/policyengine_uk/variables/contrib/labour/private_school_vat.py @@ -9,7 +9,7 @@ class private_school_vat(Variable): unit = "currency-GBP" def formula(household, period, parameters): - num_children = add(household, period, ["attends_private_school"]) + num_private_school_pupils = add(household, period, ["attends_private_school"]) ps_vat_params = parameters(period).gov.simulation.private_school_vat private_school_vat_basis = ps_vat_params.private_school_vat_basis @@ -20,7 +20,7 @@ def formula(household, period, parameters): ).gov.contrib.labour.private_school_vat return ( - num_children + num_private_school_pupils * avg_yearly_private_school_cost * private_school_vat_rate * private_school_vat_basis diff --git a/policyengine_uk/variables/household/demographic/household_equivalisation_ahc.py b/policyengine_uk/variables/household/demographic/household_equivalisation_ahc.py index 6be6286ef8..075f1de0a4 100644 --- a/policyengine_uk/variables/household/demographic/household_equivalisation_ahc.py +++ b/policyengine_uk/variables/household/demographic/household_equivalisation_ahc.py @@ -5,18 +5,25 @@ class household_equivalisation_ahc(Variable): value_type = float entity = Household label = "Equivalisation factor to account for household composition, after housing costs" + documentation = ( + "The HBAI modified OECD equivalence scale (AHC): a weight for the first " + "adult, a weight for each other adult, and weights for dependent " + "children under 14 and aged 14 or over. Adults and dependent children " + "follow the HBAI definitions." + ) definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#equivalisation-1" def formula(household, period, parameters): scale = parameters(period).household.demographic.equiv.ahc count_other_adults = max_( - household.sum(household.members("is_adult", period)) - 1, 0 + household.sum(household.members("is_hbai_adult", period)) - 1, 0 ) count_young_children = household.sum( - household.members("is_young_child", period) + household.members("is_hbai_child_under_14", period) ) count_older_children = household.sum( - household.members("is_older_child", period) + household.members("is_hbai_child_aged_14_or_over", period) ) return ( scale.first_adult diff --git a/policyengine_uk/variables/household/demographic/household_equivalisation_bhc.py b/policyengine_uk/variables/household/demographic/household_equivalisation_bhc.py index a133022af6..0c77033de9 100644 --- a/policyengine_uk/variables/household/demographic/household_equivalisation_bhc.py +++ b/policyengine_uk/variables/household/demographic/household_equivalisation_bhc.py @@ -5,18 +5,25 @@ class household_equivalisation_bhc(Variable): value_type = float entity = Household label = "Equivalisation factor to account for household composition, before housing costs" + documentation = ( + "The HBAI modified OECD equivalence scale (BHC): a weight for the first " + "adult, a weight for each other adult, and weights for dependent " + "children under 14 and aged 14 or over. Adults and dependent children " + "follow the HBAI definitions." + ) definition_period = YEAR + reference = "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#equivalisation-1" def formula(household, period, parameters): scale = parameters(period).household.demographic.equiv.bhc count_other_adults = max_( - household.sum(household.members("is_adult", period)) - 1, 0 + household.sum(household.members("is_hbai_adult", period)) - 1, 0 ) count_young_children = household.sum( - household.members("is_young_child", period) + household.members("is_hbai_child_under_14", period) ) count_older_children = household.sum( - household.members("is_older_child", period) + household.members("is_hbai_child_aged_14_or_over", period) ) return ( scale.first_adult From 409633a4ab089e489ebc5c0d29fce2b25ab0cc70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:30:00 -0400 Subject: [PATCH 04/24] Limit Marriage Allowance and Tax-Free Childcare to the couple, not their children Marriage Allowance is an election between spouses or civil partners (ITA 2007 ss.55B-55C) and the Tax-Free Childcare work condition applies to the person and their partner (Childcare Payments Act 2014 s.3; SI 2015/448 regs 3 and 9). Both now use is_claimant_or_partner instead of an age-18 test. The CPS marriage reforms follow suit, and their child-age test now counts children under the limit, as the parameters describe. The CGT marginal-rate index keeps its explicit age-18 condition. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/dynamics/participation.py | 2 +- .../reforms/cps/marriage_tax_reforms.py | 38 ++++-- .../allowances/marriage_allowance.yaml | 52 ++++++++ .../tax_free_childcare_work_condition.yaml | 112 ++++++++++++++---- .../reforms/cps/marriage_tax_reforms.yaml | 33 ++++++ .../hmrc/capital_gains_tax/adult_index_cg.py | 11 +- .../allowances/marriage_allowance.py | 7 +- .../partners_unused_personal_allowance.py | 12 +- .../tax_free_childcare_work_condition.py | 28 ++++- 9 files changed, 243 insertions(+), 52 deletions(-) diff --git a/policyengine_uk/dynamics/participation.py b/policyengine_uk/dynamics/participation.py index 728bd4704c..f94af4fd79 100644 --- a/policyengine_uk/dynamics/participation.py +++ b/policyengine_uk/dynamics/participation.py @@ -57,7 +57,7 @@ def calculate_participation_elasticities( "benunit_id": benunit_id, "is_aged_18_or_over": adult_index > 0, "employed": employment_income > 0, - "benunit_count_adults": benunit_count_adults, + "count_aged_18_or_over": benunit_count_adults, } ) diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index c10ff49f3b..813cfc4570 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -13,13 +13,17 @@ class meets_expanded_ma_conditions(Variable): value_type = bool def formula(person, period): - # There is a child who either meets the age condition or the education condition + # The couple has a child (a benefit-unit member other than the + # couple) under the age limit, or meeting the education condition. benunit = person.benunit + is_couple_child = ~person("is_claimant_or_partner", period) if max_child_age is not None: - child_meets_age_condition = person("age", period) <= max_child_age + child_meets_age_condition = is_couple_child & ( + person("age", period) < max_child_age + ) return benunit.any(child_meets_age_condition) if child_education_levels is not None: - child_meets_education_condition = np.isin( + child_meets_education_condition = is_couple_child & np.isin( person("education_level", period).decode_to_str(), child_education_levels, ) @@ -58,8 +62,10 @@ class marriage_allowance(Variable): def formula(person, period, parameters): marital = person("marital_status", period) married = marital == marital.possible_values.MARRIED - eligible = married & person( - "meets_marriage_allowance_income_conditions", period + eligible = ( + married + & person("is_claimant_or_partner", period) + & person("meets_marriage_allowance_income_conditions", period) ) transferable_amount = person("partners_unused_personal_allowance", period) allowances = parameters(period).gov.hmrc.income_tax.allowances @@ -73,7 +79,9 @@ def formula(person, period, parameters): capped_percentage, ) max_amount = allowances.personal_allowance.amount * capped_percentage - amount_if_eligible_pre_rounding = min_(transferable_amount, max_amount) + amount_if_eligible_pre_rounding = max_( + min_(transferable_amount, max_amount), 0 + ) # Round up. rounding_increment = allowances.marriage_allowance.rounding_increment amount_if_eligible = ( @@ -103,13 +111,17 @@ class meets_ma_neutral_tax_conditions(Variable): value_type = bool def formula(person, period): - # There is a child who either meets the age condition or the education condition + # The couple has a child (a benefit-unit member other than the + # couple) under the age limit, or meeting the education condition. benunit = person.benunit + is_couple_child = ~person("is_claimant_or_partner", period) if max_child_age is not None: - child_meets_age_condition = person("age", period) <= max_child_age + child_meets_age_condition = is_couple_child & ( + person("age", period) < max_child_age + ) return benunit.any(child_meets_age_condition) if child_education_levels is not None: - child_meets_education_condition = np.isin( + child_meets_education_condition = is_couple_child & np.isin( person("education_level", period).decode_to_str(), child_education_levels, ) @@ -150,9 +162,11 @@ class adjusted_net_income(Variable): def formula(person, period, parameters): income = person("unadjusted_net_income", period) - is_adult = person("is_adult", period) - total_income = person.benunit.sum(is_adult * income) - has_spouse = person.benunit("is_married", period) & is_adult + # Split between the spouses or civil partners only, never their + # children. + couple_member = person("is_claimant_or_partner", period) + total_income = person.benunit.sum(couple_member * income) + has_spouse = person.benunit("is_married", period) & couple_member originally_split_income_branch = person.simulation.get_branch( "originally_split_income", clone_system=True diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 521f62d955..ab5901837f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -62,3 +62,55 @@ output: marriage_allowance: [0, 0, 0, 0] income_tax: [9_432, 4_486, 0, 0] + +# Marriage Allowance is an election between spouses or civil partners (ITA 2007 +# s.55B, s.55C(1)(a)): the claimant and partner of a married benefit unit, never +# a child in it, whatever the child's age. +- name: An 18-year-old dependant's unused allowance does not transfer to a parent + # p1 is a basic-rate taxpayer with no unused allowance of their own spouse + # (p2 earns 60,000), so p1 gets no Marriage Allowance. Previously the + # 18-year-old's unused 12,570 counted as a "partner's" allowance and p1 got + # 1,260 (a 252 tax cut). + period: 2025 + input: + people: + p1: + age: 45 + is_parent: true + employment_income: 30_000 + p2: + age: 45 + is_parent: true + employment_income: 60_000 + student: + age: 18 + current_education: UPPER_SECONDARY + benunit: + members: [p1, p2, student] + is_married: true + output: + partners_unused_personal_allowance: [0, 0, 0] + marriage_allowance: [0, 0, 0] + marital_status: [MARRIED, MARRIED, SINGLE] + +- name: A lone parent with a 19-year-old at school is not married + # With an identified parent, the 19-year-old in non-advanced education is a + # dependant, so the benefit unit has no couple and no Marriage Allowance. + # Previously two members aged 18+ made the unit "married" by default and the + # parent got 1,260 transferred from the 19-year-old. + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + employment_income: 40_000 + student: + age: 19 + current_education: UPPER_SECONDARY + employment_income: 1_000 + benunit: + members: [parent, student] + output: + is_married: false + marriage_allowance: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml index 684d8a48b0..d17c4a4b8d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml @@ -3,11 +3,9 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 0 spouse: - is_adult: true in_work: true incapacity_benefit: 0 benunits: @@ -21,12 +19,10 @@ input: people: person: - is_adult: true is_parent: true in_work: false incapacity_benefit: 0 spouse: - is_adult: true in_work: false is_parent: true incapacity_benefit: 0 @@ -41,11 +37,9 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 0 spouse: - is_adult: true in_work: false incapacity_benefit: 100 benunits: @@ -59,11 +53,9 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 0 spouse: - is_adult: true in_work: false incapacity_benefit: 100 benunits: @@ -77,12 +69,10 @@ input: people: person: - is_adult: true in_work: true is_parent: true incapacity_benefit: 0 spouse: - is_adult: true in_work: false is_parent: true incapacity_benefit: 0 @@ -97,7 +87,6 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 0 benunits: @@ -111,7 +100,6 @@ input: people: person: - is_adult: true in_work: false incapacity_benefit: 0 benunits: @@ -125,11 +113,9 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 100 spouse: - is_adult: true in_work: false incapacity_benefit: 100 benunits: @@ -143,7 +129,6 @@ input: people: person: - is_adult: true in_work: true incapacity_benefit: 100 benunits: @@ -157,7 +142,6 @@ input: people: person: - is_adult: true in_work: false incapacity_benefit: 100 benunits: @@ -171,7 +155,6 @@ input: people: person: - is_adult: true in_work: false tax_free_childcare_on_qualifying_leave: true benunits: @@ -185,11 +168,9 @@ input: people: person: - is_adult: true is_parent: true in_work: true spouse: - is_adult: true is_parent: true in_work: false tax_free_childcare_on_qualifying_leave: true @@ -204,11 +185,9 @@ input: people: person: - is_adult: true is_parent: true in_work: true spouse: - is_adult: true is_parent: true in_work: false statutory_maternity_pay: 100 @@ -223,11 +202,9 @@ input: people: person: - is_adult: true is_parent: true in_work: true spouse: - is_adult: true is_parent: true in_work: false tax_free_childcare_on_adoption_leave: true @@ -242,11 +219,9 @@ input: people: person: - is_adult: true is_parent: true in_work: true spouse: - is_adult: true is_parent: true in_work: false tax_free_childcare_on_shared_parental_leave: true @@ -255,3 +230,90 @@ members: [person, spouse] output: tax_free_childcare_work_condition: true + +# The work condition applies to the person applying and their partner +# (Childcare Payments Act 2014 s.3(1), s.6, s.9; SI 2015/448 regs 3 and 9): +# the claimant or partner of the benefit unit, aged 16 or over. A child's own +# work does not count, and a benefit unit with no one aged 18 or over is not a +# couple. + +- name: A working 17-year-old child does not meet a lone parent's work condition + period: 2025 + input: + people: + parent: + age: 40 + is_parent: true + in_work: false + incapacity_benefit: 0 + teenager: + age: 17 + in_work: true + incapacity_benefit: 0 + benunits: + benunit: + members: [parent, teenager] + output: + tax_free_childcare_work_condition: [false, false] + +- name: A non-working 17-year-old lone parent is a single applicant, not a couple + period: 2025 + input: + people: + parent: + age: 17 + in_work: false + incapacity_benefit: 0 + baby: + age: 0 + in_work: false + incapacity_benefit: 0 + benunits: + benunit: + members: [parent, baby] + output: + tax_free_childcare_work_condition: [false, false] + +- name: A working 17-year-old lone parent meets the work condition + period: 2025 + input: + people: + parent: + age: 17 + is_parent: true + in_work: true + incapacity_benefit: 0 + baby: + age: 0 + in_work: false + incapacity_benefit: 0 + benunits: + benunit: + members: [parent, baby] + output: + tax_free_childcare_work_condition: [true, false] + +- name: A couple where only one works is not rescued by an 18-year-old dependant + period: 2025 + input: + people: + parent_1: + age: 40 + is_parent: true + in_work: true + incapacity_benefit: 0 + parent_2: + age: 40 + is_parent: true + in_work: false + incapacity_benefit: 0 + student: + age: 18 + current_education: UPPER_SECONDARY + in_work: true + incapacity_benefit: 0 + benunits: + benunit: + members: [parent_1, parent_2, student] + output: + tax_free_childcare_work_condition: [false, false, false] diff --git a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml index 0b70c7eeb7..c557150fb9 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -152,3 +152,36 @@ members: [high_earner, low_earner] output: marriage_allowance: [0, 0] + +- name: Marriage-neutral income tax — a dependant's income stays out of the couple's split + # Only the spouses' incomes are pooled: (60,000 + 10,000) / 2 = 35,000 each, + # which lowers their combined tax. The 19-year-old dependant at school keeps + # their own 8,000. Previously the dependant counted as a third adult, which + # blocked the split. + period: 2024 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.marriage_neutral_it_reform + input: + people: + high_earner: + age: 45 + is_parent: true + employment_income: 60_000 + low_earner: + age: 45 + is_parent: true + employment_income: 10_000 + student: + age: 19 + current_education: UPPER_SECONDARY + employment_income: 8_000 + benunits: + benunit: + members: [high_earner, low_earner, student] + is_married: true + households: + household: + members: [high_earner, low_earner, student] + output: + adjusted_net_income: [35_000, 35_000, 8_000] diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/adult_index_cg.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/adult_index_cg.py index 3cd2f2d849..c2d45584ce 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/adult_index_cg.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/adult_index_cg.py @@ -5,7 +5,14 @@ class adult_index_cg(Variable): value_type = int entity = Person - label = "index of adult in household, ranked by capital gains" + label = "rank by capital gains among household members aged 18 or over" + documentation = ( + "Ranks household members aged 18 or over by capital gains, largest " + "first, starting at 1; 0 for everyone under 18. It picks whose " + "marginal capital gains tax rate is simulated (two branch simulations " + "per household). A modelling choice, not law: capital gains tax has " + "no age condition." + ) definition_period = YEAR def formula(person, period, parameters): @@ -13,7 +20,7 @@ def formula(person, period, parameters): person.get_rank( person.household, -person("capital_gains_before_response", period), - condition=person("is_adult", period), + condition=~person("age_under_18", period), ) + 1 ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index a848ad9931..d54f76158a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -27,10 +27,13 @@ class marriage_allowance(Variable): def formula(person, period, parameters): marital = person("marital_status", period) married = marital == marital.possible_values.MARRIED - is_adult = person("is_adult", period) + # The election is between spouses or civil partners (ITA 2007 + # s.55C(1)(a)): the claimant and partner of a married benefit unit, + # never their children. + spouse_or_civil_partner = person("is_claimant_or_partner", period) eligible = ( married - & is_adult + & spouse_or_civil_partner & person("meets_marriage_allowance_income_conditions", period) ) transferable_amount = person("partners_unused_personal_allowance", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py index 8d26ee5cb1..9f18828b9f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py @@ -4,6 +4,7 @@ class partners_unused_personal_allowance(Variable): label = "Partner's unused personal allowance" + reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" documentation = ( "The personal tax allowance not used by this person's partner, if they exist" ) @@ -13,9 +14,10 @@ class partners_unused_personal_allowance(Variable): unit = GBP def formula(person, period, parameters): - is_adult = person("is_adult", period) + # Only a spouse or civil partner can transfer allowance (ITA 2007 + # s.55B(5A), s.55C(1)(a)): the other claimant or partner of the + # benefit unit, never a child. People outside the couple get zero, so + # they cannot produce a negative transferable amount. + couple_member = person("is_claimant_or_partner", period) pa = person("unused_personal_allowance", period) - # Subtract this person's own unused PA only if they are an adult, so - # non-adults (whose PA isn't part of the adult-summed pool) cannot - # produce a negative transferable amount. - return person.benunit.sum(is_adult * pa) - is_adult * pa + return person.benunit.sum(couple_member * pa) - couple_member * pa diff --git a/policyengine_uk/variables/gov/hmrc/tax_free_childcare/conditions/tax_free_childcare_work_condition.py b/policyengine_uk/variables/gov/hmrc/tax_free_childcare/conditions/tax_free_childcare_work_condition.py index d9e0dd1fb7..18352d4172 100644 --- a/policyengine_uk/variables/gov/hmrc/tax_free_childcare/conditions/tax_free_childcare_work_condition.py +++ b/policyengine_uk/variables/gov/hmrc/tax_free_childcare/conditions/tax_free_childcare_work_condition.py @@ -5,11 +5,27 @@ class tax_free_childcare_work_condition(Variable): value_type = bool entity = Person label = "work conditions for tax-free childcare" + documentation = ( + "The person applying and, if they have one, their partner must be " + "in qualifying paid work, unless the partner has a qualifying " + "disability or incapacity. Both must be at least 16. Children in the " + "family play no part." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2014/28/section/3", + "https://www.legislation.gov.uk/ukpga/2014/28/section/6", + "https://www.legislation.gov.uk/uksi/2015/448/regulation/3", + "https://www.legislation.gov.uk/uksi/2015/448/regulation/9", + ) def formula(person, period, parameters): benunit = person.benunit - is_adult = person("is_adult", period) + # The person applying and their partner (Childcare Payments Act 2014 + # s.3(1), s.6; SI 2015/448 reg 3): the claimant or partner, aged 16+. + applicant_or_partner = person("is_claimant_or_partner", period) & person( + "over_16", period + ) treated_as_in_work = person( "tax_free_childcare_treated_as_in_work", @@ -35,14 +51,16 @@ def formula(person, period, parameters): # Build conditions # Single adult conditions is_single = person.benunit("is_single", period) - single_working = is_single & treated_as_in_work + single_working = is_single & treated_as_in_work & applicant_or_partner # Couple conditions is_couple = person.benunit("is_couple", period) - benunit_has_condition = benunit.any(eligible_based_on_disability & is_adult) - benunit_has_worker = benunit.any(treated_as_in_work & is_adult) + benunit_has_condition = benunit.any( + eligible_based_on_disability & applicant_or_partner + ) + benunit_has_worker = benunit.any(treated_as_in_work & applicant_or_partner) couple_both_working = is_couple & benunit.all( - treated_as_in_work | ~person("is_parent", period) + treated_as_in_work | ~applicant_or_partner ) couple_one_working_one_disabled = ( is_couple & benunit_has_worker & benunit_has_condition From 94413ce9b8f26d5ed1dc43af839ad7554cdfc2ac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:31:26 -0400 Subject: [PATCH 05/24] Use each DfE scheme's own definition of a child or the student's family Care to Learn: the parent must get Child Benefit for the child, so the parent no longer counts as their own child. Parents' Learning Allowance and Childcare Grant: a dependent child of the student (SI 2011/1986 regs 42, 45, 46), with no age limit of its own. Extended childcare: the income test applies to the parent and partner only (Childcare Act 2016 s.1; SI 2022/1134 regs 14-15). Maintenance loans drop a redundant age-18 test. Co-Authored-By: Claude Opus 5.5 --- .../adult_dependants_grant.yaml | 23 +++++ .../bursary_fund_16_to_19.yaml | 1 - .../care_to_learn/care_to_learn_eligible.yaml | 52 +++++++++-- .../dfe/childcare_grant/childcare_grant.yaml | 15 ---- ...tended_childcare_entitlement_eligible.yaml | 90 +++++++++++++++---- .../maintenance_loans/maintenance_loan.yaml | 2 - .../parents_learning_allowance.yaml | 34 +++++-- .../care_to_learn/care_to_learn_eligible.py | 16 +++- .../childcare_grant_child_eligible.py | 7 +- ...extended_childcare_entitlement_eligible.py | 11 ++- .../is_dependent_child_for_student_support.py | 25 ++++++ .../maintenance_loan_has_sponsor.py | 3 +- .../maintenance_loan_sponsor_income.py | 3 +- ...s_learning_allowance_dependent_children.py | 7 +- 14 files changed, 224 insertions(+), 65 deletions(-) create mode 100644 policyengine_uk/variables/gov/dfe/is_dependent_child_for_student_support.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/adult_dependants_grant/adult_dependants_grant.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/adult_dependants_grant/adult_dependants_grant.yaml index f54ef1f943..11cedc695c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/adult_dependants_grant/adult_dependants_grant.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/adult_dependants_grant/adult_dependants_grant.yaml @@ -209,3 +209,26 @@ output: adult_dependants_grant_eligible: [false, false] adult_dependants_grant: [0, 0] + +# The partner route needs a partner (SI 2011/1986 reg 44(2)(a)); an older child +# still at school is a dependant, not a partner. +- name: A lone parent with an 18-year-old at school has no partner for Adult Dependants' Grant + period: 2025 + input: + people: + student: + age: 30 + is_parent: true + teenager: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [student, teenager] + households: + household: + members: [student, teenager] + country: ENGLAND + output: + is_couple: false + adult_dependants_grant_adult_eligible: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml index 26726e4efa..33600f00ac 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml @@ -134,7 +134,6 @@ pip_dl_category: STANDARD sibling: age: 10 - is_child: true benunits: benunit: members: ["parent", "student", "sibling"] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_eligible.yaml index 88966209eb..1628789ff6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_eligible.yaml @@ -9,7 +9,6 @@ is_apprentice: false child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -31,7 +30,6 @@ is_apprentice: false child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -72,7 +70,6 @@ is_apprentice: false child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -94,7 +91,6 @@ is_apprentice: true child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -116,7 +112,6 @@ is_apprentice: false child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -138,10 +133,8 @@ is_apprentice: false child1: age: 2 - is_child: true child2: age: 4 - is_child: true benunits: benunit: members: ["parent", "child1", "child2"] @@ -163,7 +156,6 @@ is_apprentice: false child: age: 2 - is_child: true benunits: benunit: members: ["parent", "child"] @@ -172,4 +164,46 @@ members: ["parent", "child"] country: ENGLAND output: - care_to_learn_eligible: [false, false] \ No newline at end of file + care_to_learn_eligible: [false, false] + +# The young parent must receive Child Benefit for the child they claim for +# (ESFA conditions of grant), so the parent can never count as their own child. +- name: Care to Learn - A 17-year-old parent with no child in the benefit unit + period: 2024 + input: + people: + parent: + age: 17 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + benunits: + benunit: + members: ["parent"] + households: + household: + members: ["parent"] + country: ENGLAND + output: + care_to_learn_eligible: false + +- name: Care to Learn - A 17-year-old parent with a baby + period: 2024 + input: + people: + parent: + age: 17 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + baby: + age: 0 + benunits: + benunit: + members: ["parent", "baby"] + households: + household: + members: ["parent", "baby"] + country: ENGLAND + output: + care_to_learn_eligible: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml index ef00a4b516..760c906392 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml @@ -10,7 +10,6 @@ childcare_expenses: 5_000 child: age: 4 - is_child: true benunits: benunit: members: ["student", "child"] @@ -35,7 +34,6 @@ childcare_expenses: 20_000 child: age: 3 - is_child: true benunits: benunit: members: ["student", "child"] @@ -59,10 +57,8 @@ childcare_expenses: 25_000 child_1: age: 6 - is_child: true child_2: age: 8 - is_child: true benunits: benunit: members: ["student", "child_1", "child_2"] @@ -86,7 +82,6 @@ childcare_grant_household_income: 20_107.23 child: age: 2 - is_child: true benunits: benunit: members: ["student", "child"] @@ -110,7 +105,6 @@ childcare_expenses: 4_000 child: age: 16 - is_child: true childcare_grant_child_has_special_educational_needs: true benunits: benunit: @@ -135,7 +129,6 @@ childcare_expenses: 4_000 child: age: 16 - is_child: true benunits: benunit: members: ["student", "child"] @@ -158,7 +151,6 @@ childcare_expenses: 4_000 child: age: 16 - is_child: true is_disabled_for_benefits: true benunits: benunit: @@ -183,7 +175,6 @@ childcare_expenses: 6_000 child: age: 4 - is_child: true benunits: benunit: members: ["student", "child"] @@ -210,7 +201,6 @@ childcare_grant_receives_postgraduate_loan: true child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] @@ -235,7 +225,6 @@ childcare_grant_receives_nhs_childcare_support: true child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] @@ -258,7 +247,6 @@ childcare_expenses: 6_000 child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] @@ -282,7 +270,6 @@ childcare_expenses: 6_000 child: age: 5 - is_child: true tax_free_childcare: 1_000 benunits: benunit: @@ -308,7 +295,6 @@ childcare_grant_provider_is_nanny: true child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] @@ -335,7 +321,6 @@ working_tax_credit_reported: 1 child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml index 9b979a3893..53f74422a2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml @@ -7,15 +7,15 @@ country: ENGLAND people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: true parent2: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: false child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: @@ -32,15 +32,15 @@ country: SCOTLAND people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: true parent2: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: false child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: @@ -57,15 +57,15 @@ country: ENGLAND people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: false extended_childcare_entitlement_work_condition: true parent2: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: false child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: @@ -82,15 +82,15 @@ country: ENGLAND people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: false parent2: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: false child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: @@ -107,11 +107,11 @@ country: ENGLAND people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: true child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: @@ -128,14 +128,70 @@ country: WALES people: parent1: - is_child: false + age: 35 extended_childcare_entitlement_meets_income_requirements: true extended_childcare_entitlement_work_condition: true child1: - is_child: true + age: 3 extended_childcare_entitlement_work_condition: false benunits: benunit1: members: [parent1, child1] output: - extended_childcare_entitlement_eligible: false \ No newline at end of file + extended_childcare_entitlement_eligible: false +# The income conditions apply to the parent and the parent's partner +# (Childcare Act 2016 s.1(2)(d); SI 2022/1134 regs 14, 15 and 18), not to an +# older child still at school. +- name: Eligible - an 18-year-old at school does not have to meet the income test + period: 2025 + input: + households: + household1: + members: [parent1, parent2, student, child1] + country: ENGLAND + people: + parent1: + age: 35 + is_parent: true + extended_childcare_entitlement_meets_income_requirements: true + extended_childcare_entitlement_work_condition: true + parent2: + age: 35 + is_parent: true + extended_childcare_entitlement_meets_income_requirements: true + extended_childcare_entitlement_work_condition: false + student: + age: 18 + current_education: UPPER_SECONDARY + extended_childcare_entitlement_meets_income_requirements: false + extended_childcare_entitlement_work_condition: false + child1: + age: 3 + extended_childcare_entitlement_work_condition: false + benunits: + benunit1: + members: [parent1, parent2, student, child1] + output: + extended_childcare_entitlement_eligible: true + +- name: Not eligible - a 17-year-old parent must meet the income test + period: 2025 + input: + households: + household1: + members: [parent1, child1] + country: ENGLAND + people: + parent1: + age: 17 + is_parent: true + extended_childcare_entitlement_meets_income_requirements: false + extended_childcare_entitlement_work_condition: true + child1: + age: 1 + extended_childcare_entitlement_work_condition: false + benunits: + benunit1: + members: [parent1, child1] + output: + extended_childcare_entitlement_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/maintenance_loans/maintenance_loan.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/maintenance_loans/maintenance_loan.yaml index fa61e7cc17..2c53b50689 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/maintenance_loans/maintenance_loan.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/maintenance_loans/maintenance_loan.yaml @@ -70,7 +70,6 @@ is_parent: true child: age: 1 - is_child: true benunits: benunit: members: ["student", "child"] @@ -432,7 +431,6 @@ is_parent: true child: age: 1 - is_child: true benunits: grandparent_benunit: members: ["grandparent"] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/parents_learning_allowance/parents_learning_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/parents_learning_allowance/parents_learning_allowance.yaml index 858ecc4c78..566e371ab9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/parents_learning_allowance/parents_learning_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/parents_learning_allowance/parents_learning_allowance.yaml @@ -9,7 +9,6 @@ parents_learning_allowance_household_income: 0 child: age: 4 - is_child: true benunits: benunit: members: ["student", "child"] @@ -32,7 +31,6 @@ parents_learning_allowance_household_income: 18_957.97 child: age: 7 - is_child: true benunits: benunit: members: ["student", "child"] @@ -55,7 +53,6 @@ parents_learning_allowance_household_income: 18_957.98 child: age: 3 - is_child: true benunits: benunit: members: ["student", "child"] @@ -78,7 +75,6 @@ current_education: TERTIARY child: age: 2 - is_child: true benunits: benunit: members: ["student", "child"] @@ -105,7 +101,6 @@ parents_learning_allowance_household_income: 10_000 child: age: 5 - is_child: true benunits: benunit: members: ["student", "child"] @@ -129,7 +124,6 @@ parents_learning_allowance_household_income: 0 child: age: 4 - is_child: true benunits: benunit: members: ["student", "child"] @@ -152,7 +146,6 @@ parents_learning_allowance_household_income: 0 child: age: 1 - is_child: true benunits: benunit: members: ["student", "child"] @@ -163,3 +156,30 @@ output: parents_learning_allowance_eligible: [false, false] parents_learning_allowance: [0, 0] + +# A dependent child has no age limit for Parents' Learning Allowance +# (Education (Student Support) Regulations 2011 regs 42 and 46): a student whose +# only dependant is an 18-year-old still at school has a dependent child. +- name: Parents' Learning Allowance counts an 18-year-old dependant at school + period: 2025 + input: + people: + student: + age: 40 + is_parent: true + parents_learning_allowance_course_eligible: true + parents_learning_allowance_household_income: 0 + teenager: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: ["student", "teenager"] + households: + household: + members: ["student", "teenager"] + country: ENGLAND + output: + parents_learning_allowance_dependent_children: 1 + parents_learning_allowance_eligible: [true, false] + parents_learning_allowance: [2_024, 0] diff --git a/policyengine_uk/variables/gov/dfe/care_to_learn/care_to_learn_eligible.py b/policyengine_uk/variables/gov/dfe/care_to_learn/care_to_learn_eligible.py index 8e5d70a2eb..ccc4d49f1d 100644 --- a/policyengine_uk/variables/gov/dfe/care_to_learn/care_to_learn_eligible.py +++ b/policyengine_uk/variables/gov/dfe/care_to_learn/care_to_learn_eligible.py @@ -7,6 +7,10 @@ class care_to_learn_eligible(Variable): label = "eligible for Care to Learn childcare support" definition_period = YEAR defined_for = "would_claim_care_to_learn" + reference = ( + "https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding", + "https://www.legislation.gov.uk/ukpga/1992/4/section/142", + ) def formula(person, period, parameters): # Link for instruction: https://www.gov.uk/care-to-learn/eligibility @@ -14,8 +18,14 @@ def formula(person, period, parameters): # Only parents can be eligible, not children is_parent = person("is_parent", period) - # Check basic eligibility conditions - benunit_has_children = person.benunit.any(person("is_child", period)) + # The young parent must be the main carer of, and receive Child + # Benefit for, the child they claim for (ESFA conditions of grant). + # So the child is a Child Benefit child or qualifying young person in + # the benefit unit, other than the parent themselves. + cares_for_child_benefit_child = person.benunit.any( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~is_parent + ) p = parameters(period).gov.dfe.care_to_learn age_eligible = person("age", period) < p.age_limit @@ -34,7 +44,7 @@ def formula(person, period, parameters): return ( is_parent - & benunit_has_children + & cares_for_child_benefit_child & age_eligible & not_higher_education & lives_in_england diff --git a/policyengine_uk/variables/gov/dfe/childcare_grant/childcare_grant_child_eligible.py b/policyengine_uk/variables/gov/dfe/childcare_grant/childcare_grant_child_eligible.py index d065ee4b9a..36a2b5e9b1 100644 --- a/policyengine_uk/variables/gov/dfe/childcare_grant/childcare_grant_child_eligible.py +++ b/policyengine_uk/variables/gov/dfe/childcare_grant/childcare_grant_child_eligible.py @@ -6,10 +6,13 @@ class childcare_grant_child_eligible(Variable): entity = Person label = "Child is eligible for Childcare Grant" documentation = ( - "Whether the child satisfies the Childcare Grant child-age condition." + "Whether a dependent child of the student satisfies the Childcare " + "Grant age condition: under 15, or under 17 with special educational " + "needs, immediately before the academic year begins." ) definition_period = YEAR - defined_for = "is_child" + defined_for = "is_dependent_child_for_student_support" + reference = "https://www.legislation.gov.uk/uksi/2011/1986/regulation/45" def formula(person, period, parameters): p = parameters(period).gov.dfe.childcare_grant.eligible_child.max_age diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.py index dc2ffb606a..0e5f5bc408 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.py @@ -7,6 +7,11 @@ class extended_childcare_entitlement_eligible(Variable): label = "eligibility for extended childcare entitlement" definition_period = YEAR defined_for = "would_claim_extended_childcare" + reference = ( + "https://www.legislation.gov.uk/ukpga/2016/5/section/1", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/14", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/15", + ) def formula(benunit, period, parameters): # Check if household is in England @@ -14,12 +19,14 @@ def formula(benunit, period, parameters): countries = country.possible_values in_england = country == countries.ENGLAND - # Check income condition - must be true for all family members (except children) + # The income conditions apply to the parent and the parent's partner + # (Childcare Act 2016 s.1(2)(d); SI 2022/1134 regs 14, 15 and 18): + # the claimant and partner of the benefit unit, not their children. person = benunit.members person_meets_income_condition = person( "extended_childcare_entitlement_meets_income_requirements", period, - ) | person("is_child", period) + ) | ~person("is_claimant_or_partner", period) meets_income_condition = benunit.all(person_meets_income_condition) # Check work condition diff --git a/policyengine_uk/variables/gov/dfe/is_dependent_child_for_student_support.py b/policyengine_uk/variables/gov/dfe/is_dependent_child_for_student_support.py new file mode 100644 index 0000000000..bfbc366ec4 --- /dev/null +++ b/policyengine_uk/variables/gov/dfe/is_dependent_child_for_student_support.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class is_dependent_child_for_student_support(Variable): + """Dependent child of a student for student support in England. + + A dependent child is a child of the student or of their partner, or one the + student has parental responsibility for, who is wholly or mainly + financially dependent on the student (Education (Student Support) + Regulations 2011 reg 42(1)). The regulations set no age limit; each grant + applies its own (for example, Childcare Grant ages under reg 45(2)). + + Financial dependence is not observed, so the children and young persons in + the student's benefit unit (everyone other than the claimant or partner) + stand in for it. + """ + + value_type = bool + entity = Person + label = "Dependent child for student support" + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2011/1986/regulation/42" + + def formula(person, period, parameters): + return ~person("is_claimant_or_partner", period) diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_has_sponsor.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_has_sponsor.py index 338ac9ea64..cc69cb761d 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_has_sponsor.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_has_sponsor.py @@ -13,10 +13,9 @@ def formula(person, period, parameters): age = person("age", period) in_higher_education = person("maintenance_loan_in_higher_education", period) adult_index = person("adult_index", period) - is_adult = person("is_adult", period) in_he = in_higher_education | person("in_HE", period) - sponsor_candidate = is_adult & np.logical_not(in_he) & (age >= 29) + sponsor_candidate = np.logical_not(in_he) & (age >= 29) head_candidate = sponsor_candidate & (adult_index == 1) second_candidate = sponsor_candidate & (adult_index == 2) diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_sponsor_income.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_sponsor_income.py index f4789f2f1b..02e89cf2a0 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_sponsor_income.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_sponsor_income.py @@ -17,11 +17,10 @@ def formula(person, period, parameters): age = person("age", period) in_higher_education = person("maintenance_loan_in_higher_education", period) adult_index = person("adult_index", period) - is_adult = person("is_adult", period) in_he = in_higher_education | person("in_HE", period) benunit_income = person("maintenance_loan_candidate_benunit_income", period) - sponsor_candidate = is_adult & np.logical_not(in_he) & (age >= 29) + sponsor_candidate = np.logical_not(in_he) & (age >= 29) head_candidate = sponsor_candidate & (adult_index == 1) second_candidate = sponsor_candidate & (adult_index == 2) diff --git a/policyengine_uk/variables/gov/dfe/parents_learning_allowance/parents_learning_allowance_dependent_children.py b/policyengine_uk/variables/gov/dfe/parents_learning_allowance/parents_learning_allowance_dependent_children.py index b2e9ccfadf..f7c650fb47 100644 --- a/policyengine_uk/variables/gov/dfe/parents_learning_allowance/parents_learning_allowance_dependent_children.py +++ b/policyengine_uk/variables/gov/dfe/parents_learning_allowance/parents_learning_allowance_dependent_children.py @@ -6,10 +6,11 @@ class parents_learning_allowance_dependent_children(Variable): entity = BenUnit label = "Dependent children for Parents' Learning Allowance" documentation = ( - "Number of children in the student's benefit unit counted as dependent children for the first-pass " - "Parents' Learning Allowance model." + "Number of dependent children of the student (Education (Student " + "Support) Regulations 2011 regs 42 and 46). The law sets no age limit." ) definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2011/1986/regulation/46" def formula(benunit, period, parameters): - return benunit.sum(benunit.members("is_child", period)) + return add(benunit, period, ["is_dependent_child_for_student_support"]) From 7117902e6a9d9630dade127412cca7b45b9b3832 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:33:20 -0400 Subject: [PATCH 06/24] Apply the tax credit claimant and child definitions in Working Tax Credit The lone parent element, the disability and severe disability elements and the eligibility routes now use the claimant or joint claimants (TCA 2002 s.3) and responsibility for a child or qualifying young person under the CTC rules (SI 2002/2005 regs 4, 9, 12, 17; SI 2002/2007 reg 3), instead of age-18 counts. The childcare element counts children until the September after their 15th birthday, or 16th if disabled (reg 14(3)-(4)). Co-Authored-By: Claude Opus 5.5 --- .../childcare_child_age_limit/disabled.yaml | 9 + .../childcare_child_age_limit/standard.yaml | 9 + .../tax_credits_claimants_and_children.yaml | 184 ++++++++++++++++++ .../gov/dwp/WTC_childcare_element.py | 13 +- .../variables/gov/dwp/WTC_couple_element.py | 2 +- .../variables/gov/dwp/WTC_disabled_element.py | 4 +- .../gov/dwp/WTC_lone_parent_element.py | 11 +- .../gov/dwp/WTC_severely_disabled_element.py | 12 +- .../variables/gov/dwp/is_WTC_eligible.py | 24 ++- ...or_working_tax_credit_childcare_element.py | 36 ++++ 10 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/disabled.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/standard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_claimants_and_children.yaml create mode 100644 policyengine_uk/variables/gov/dwp/is_child_for_working_tax_credit_childcare_element.py diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/disabled.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/disabled.yaml new file mode 100644 index 0000000000..561f2af9af --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/disabled.yaml @@ -0,0 +1,9 @@ +description: Annual age below which a disabled child's childcare charges count for the Working Tax Credit childcare element, approximating the week containing the 1 September after the child's 16th birthday. +values: + 2003-04-06: 17 +metadata: + unit: year + label: WTC childcare disabled child age limit + reference: + - title: Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002 reg 14(3)-(4) + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/14 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/standard.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/standard.yaml new file mode 100644 index 0000000000..baf97f17e2 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/childcare_child_age_limit/standard.yaml @@ -0,0 +1,9 @@ +description: Annual age below which a child's childcare charges count for the Working Tax Credit childcare element, approximating the week containing the 1 September after the child's 15th birthday. +values: + 2003-04-06: 16 +metadata: + unit: year + label: WTC childcare child age limit + reference: + - title: Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002 reg 14(3) + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/14 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_claimants_and_children.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_claimants_and_children.yaml new file mode 100644 index 0000000000..0758d27e7d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_claimants_and_children.yaml @@ -0,0 +1,184 @@ +# Working Tax Credit: who is the claimant, who is a child or qualifying young +# person (QYP), and which children count for the childcare element. +# +# Claims are single or joint by the members of a couple (Tax Credits Act 2002 +# s.3); a claimant is responsible for children and QYPs under the Child Tax +# Credit rules (SI 2002/2007 regs 3 and 5). Dependants are never claimants. +# 2020-21 elements (SI 2002/2005 Sch 2): lone parent 2,045; couple 2,045; +# disabled 3,220; severe disability 1,390. Hours: 16 for lone parents and +# disabled workers, 24 for a couple with children (one working 16+), 30 at 25+. + +- name: A couple with an 18-year-old QYP and a 10-year-old needs 24 hours, not 16 + # Previously three members aged 18+ made this a "lone parent" family, eligible + # at 16 hours with both the lone parent and couple elements. + period: 2020 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 35 + is_parent: true + weekly_hours: 20 + working_tax_credit_reported: true + parent_2: + age: 33 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + child: + age: 10 + benunits: + benunit: + members: [parent_1, parent_2, student, child] + output: + is_WTC_eligible: false + +- name: A couple with an 18-year-old QYP working 26 hours gets the couple element only + period: 2020 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 35 + is_parent: true + weekly_hours: 26 + working_tax_credit_reported: true + parent_2: + age: 33 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent_1, parent_2, student] + output: + is_WTC_eligible: true + WTC_couple_element: 2_045 + WTC_lone_parent_element: 0 + +- name: A lone parent whose only dependant is an 18-year-old QYP qualifies on 16 hours + # Previously the 18-year-old made this a childless "couple" needing 30 hours. + period: 2020 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + weekly_hours: 20 + working_tax_credit_reported: true + student: + age: 18 + current_education: UPPER_SECONDARY + benunits: + benunit: + members: [parent, student] + output: + is_WTC_eligible: true + WTC_lone_parent_element: 2_045 + WTC_couple_element: 0 + +- name: A childless 17-year-old is not a lone parent and needs to be 25 for the 30-hour route + period: 2020 + absolute_error_margin: 0 + input: + people: + worker: + age: 17 + weekly_hours: 30 + working_tax_credit_reported: true + benunits: + benunit: + members: [worker] + output: + is_WTC_eligible: false + +- name: A severely disabled 18-year-old QYP gives no WTC severe disability element + # The element is for the claimant or a joint claimant (reg 17); the young + # person's severe disability counts for Child Tax Credit instead. + period: 2020 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 45 + is_parent: true + weekly_hours: 30 + working_tax_credit_reported: true + parent_2: + age: 44 + is_parent: true + student: + age: 18 + current_education: UPPER_SECONDARY + is_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + weekly_hours: 16 + benunits: + benunit: + members: [parent_1, parent_2, student] + output: + is_WTC_eligible: true + WTC_severely_disabled_element: 0 + WTC_disabled_element: 0 + +- name: A severely disabled lone claimant gets the WTC severe disability element + period: 2020 + absolute_error_margin: 0 + input: + people: + worker: + age: 30 + weekly_hours: 30 + working_tax_credit_reported: true + is_severely_disabled_for_benefits: true + benunits: + benunit: + members: [worker] + output: + WTC_severely_disabled_element: 1_390 + +- name: Childcare element counts children until the September after their 15th birthday + # A 16-year-old is past the reg 14(3) limit, so only the 10-year-old counts: + # the one-child cap applies. Cap 248.63997060 x 52 = 12,929.28; 70% of that + # is 9,050.50 (the 20,000 of charges exceed the cap). Previously the + # 16-year-old counted and the two-child cap applied. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + weekly_hours: 20 + working_tax_credit_reported: true + childcare_expenses: 20_000 + teenager: + age: 16 + child: + age: 10 + benunits: + benunit: + members: [parent, teenager, child] + output: + is_child_for_working_tax_credit_childcare_element: [false, false, true] + WTC_childcare_element: 9_050.50 + +- name: Childcare element counts a disabled child until the September after their 16th birthday + period: 2020 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + is_parent: true + teenager: + age: 16 + is_disabled_for_benefits: true + benunits: + benunit: + members: [parent, teenager] + output: + is_child_for_working_tax_credit_childcare_element: [false, true] diff --git a/policyengine_uk/variables/gov/dwp/WTC_childcare_element.py b/policyengine_uk/variables/gov/dwp/WTC_childcare_element.py index 9bff287019..cbc91abd86 100644 --- a/policyengine_uk/variables/gov/dwp/WTC_childcare_element.py +++ b/policyengine_uk/variables/gov/dwp/WTC_childcare_element.py @@ -6,15 +6,20 @@ class WTC_childcare_element(Variable): entity = BenUnit label = "Working Tax Credit childcare element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 11" + reference = ( + "https://www.legislation.gov.uk/uksi/2002/2005/regulation/14", + "https://www.legislation.gov.uk/uksi/2002/2005/regulation/20", + ) unit = GBP defined_for = "is_WTC_eligible" def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit - num_children = benunit("num_children", period) - childcare_1 = (num_children == 1) * WTC.elements.childcare_1 - childcare_2 = (num_children > 1) * WTC.elements.childcare_2 + num_childcare_children = add( + benunit, period, ["is_child_for_working_tax_credit_childcare_element"] + ) + childcare_1 = (num_childcare_children == 1) * WTC.elements.childcare_1 + childcare_2 = (num_childcare_children > 1) * WTC.elements.childcare_2 max_childcare_amount = (childcare_1 + childcare_2) * WEEKS_IN_YEAR expenses = add(benunit, period, ["childcare_expenses"]) eligible_expenses = min_(max_childcare_amount, expenses) diff --git a/policyengine_uk/variables/gov/dwp/WTC_couple_element.py b/policyengine_uk/variables/gov/dwp/WTC_couple_element.py index 08350266d0..988bc468ec 100644 --- a/policyengine_uk/variables/gov/dwp/WTC_couple_element.py +++ b/policyengine_uk/variables/gov/dwp/WTC_couple_element.py @@ -6,7 +6,7 @@ class WTC_couple_element(Variable): entity = BenUnit label = "Working Tax Credit couple element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 11" + reference = "https://www.legislation.gov.uk/uksi/2002/2005/regulation/11" unit = GBP defined_for = "is_WTC_eligible" diff --git a/policyengine_uk/variables/gov/dwp/WTC_disabled_element.py b/policyengine_uk/variables/gov/dwp/WTC_disabled_element.py index 9bbaf94f7d..958bc65d3c 100644 --- a/policyengine_uk/variables/gov/dwp/WTC_disabled_element.py +++ b/policyengine_uk/variables/gov/dwp/WTC_disabled_element.py @@ -6,7 +6,7 @@ class WTC_disabled_element(Variable): entity = BenUnit label = "Working Tax Credit disabled element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 11" + reference = "https://www.legislation.gov.uk/uksi/2002/2005/regulation/9" unit = GBP defined_for = "is_WTC_eligible" @@ -17,7 +17,7 @@ def formula(benunit, period, parameters): person_qualifies = ( person_meets_hours & person("is_disabled_for_benefits", period) - & person("is_adult", period) + & person("is_claimant_or_partner", period) ) qualifies = benunit.any(person_qualifies) return qualifies * WTC.elements.disabled diff --git a/policyengine_uk/variables/gov/dwp/WTC_lone_parent_element.py b/policyengine_uk/variables/gov/dwp/WTC_lone_parent_element.py index 8500415a73..62bdd91cef 100644 --- a/policyengine_uk/variables/gov/dwp/WTC_lone_parent_element.py +++ b/policyengine_uk/variables/gov/dwp/WTC_lone_parent_element.py @@ -6,13 +6,16 @@ class WTC_lone_parent_element(Variable): entity = BenUnit label = "Working Tax Credit lone parent element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 11" + reference = "https://www.legislation.gov.uk/uksi/2002/2005/regulation/12" unit = GBP defined_for = "is_WTC_eligible" def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit - family_type = benunit("family_type", period) - families = family_type.possible_values - lone_parent = family_type == families.LONE_PARENT + # A single claim by a claimant responsible for a child or qualifying + # young person (reg 12). + lone_parent = benunit("is_single", period) & benunit( + "is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit", + period, + ) return lone_parent * WTC.elements.lone_parent diff --git a/policyengine_uk/variables/gov/dwp/WTC_severely_disabled_element.py b/policyengine_uk/variables/gov/dwp/WTC_severely_disabled_element.py index d1bc2cfe36..1322ed9e01 100644 --- a/policyengine_uk/variables/gov/dwp/WTC_severely_disabled_element.py +++ b/policyengine_uk/variables/gov/dwp/WTC_severely_disabled_element.py @@ -6,13 +6,17 @@ class WTC_severely_disabled_element(Variable): entity = BenUnit label = "Working Tax Credit severely disabled element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 11" + reference = "https://www.legislation.gov.uk/uksi/2002/2005/regulation/17" unit = GBP def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit - amount = ( - benunit("num_severely_disabled_adults", period) - * WTC.elements.severely_disabled + person = benunit.members + # The claimant, or either joint claimant, who is severely disabled + # (reg 17); a severely disabled child counts for CTC instead. + severely_disabled_claimants = benunit.sum( + person("is_claimant_or_partner", period) + & person("is_severely_disabled_for_benefits", period) ) + amount = severely_disabled_claimants * WTC.elements.severely_disabled return benunit("is_WTC_eligible", period) * amount diff --git a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py index 9573cc48c6..9152c5078f 100644 --- a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py @@ -6,7 +6,10 @@ class is_WTC_eligible(Variable): entity = BenUnit label = "Working Tax Credit eligibility" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 10" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/21/section/10", + "https://www.legislation.gov.uk/uksi/2002/2005/regulation/4", + ) def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit @@ -14,15 +17,20 @@ def formula(benunit, period, parameters): person_hours = person("weekly_hours", period) total_hours = benunit.sum(person_hours) max_person_hours = benunit.max(person_hours) - has_disabled_adults = benunit("num_disabled_adults", period) > 0 - family_type = benunit("family_type", period) - families = family_type.possible_values + claimant = person("is_claimant_or_partner", period) + has_disabled_adults = benunit.any( + claimant & person("is_disabled_for_benefits", period) + ) + responsible_for_child = benunit( + "is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit", + period, + ) old = person("age", period.this_year) >= WTC.min_hours.old_age has_old = benunit.any(old) - lone_parent = family_type == families.LONE_PARENT - couple_with_children = family_type == families.COUPLE_WITH_CHILDREN - eldest_25_plus = benunit("eldest_adult_age", period) >= 25 - youngest_under_60 = benunit("youngest_adult_age", period) < 60 + lone_parent = benunit("is_single", period) & responsible_for_child + couple_with_children = benunit("is_couple", period) & responsible_for_child + eldest_25_plus = benunit("eldest_claimant_or_partner_age", period) >= 25 + youngest_under_60 = benunit("youngest_claimant_or_partner_age", period) < 60 # Calculate WTC eligibility group. lower_req = has_disabled_adults | has_old | lone_parent medium_req = couple_with_children & ~lower_req diff --git a/policyengine_uk/variables/gov/dwp/is_child_for_working_tax_credit_childcare_element.py b/policyengine_uk/variables/gov/dwp/is_child_for_working_tax_credit_childcare_element.py new file mode 100644 index 0000000000..4cdba5da66 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_child_for_working_tax_credit_childcare_element.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class is_child_for_working_tax_credit_childcare_element(Variable): + """Child whose childcare charges count for the WTC childcare element. + + Relevant childcare charges are for a child the claimant is responsible for + (reg 14(1)). A person is a child until the last day of the week containing + the 1 September after their 15th birthday, or 16th if disabled: receiving + DLA or PIP, or certified blind (reg 14(3)-(4)). Annual ages approximate the + September cut-off. Looked-after children are not a claimant's + responsibility (Child Tax Credit Regulations 2002 reg 3). + """ + + value_type = bool + entity = Person + label = "Child for the Working Tax Credit childcare element" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2002/2005/regulation/14", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/3", + ) + + def formula(person, period, parameters): + p = parameters( + period + ).gov.dwp.tax_credits.working_tax_credit.childcare_child_age_limit + disabled = person("is_disabled_for_benefits", period) | person( + "is_blind", period + ) + age_limit = where(disabled, p.disabled, p.standard) + return ( + ~person("is_claimant_or_partner", period) + & ~person("is_looked_after_by_local_authority", period) + & (person("age", period) < age_limit) + ) From 98853d40140d8fbfd3a5415aa0177469af146191 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:34:20 -0400 Subject: [PATCH 07/24] Assess Pension Credit on the claimant and partner, not their children Eligibility needs every claimant or partner at State Pension age (SPCA 2002 ss.1, 4(1A)); income, earnings and deductions count only the claimant and partner (s.5); the carer and severe disability additions count only them (Sch I paras 1, 4), and children no longer veto the severe disability addition (Sch I para 2(2)(f)). Built with a gpt-6-astra subfleet lane; pension_credit_earnings fixed at source instead of subtracting dependants' earnings downstream. Co-Authored-By: Claude Opus 5.5 --- .../pension-credit-claimant-partner.fixed.md | 1 + .../claimant_partner_income_and_carers.yaml | 216 ++++++++++++++++++ .../claimant_partner_rates.yaml | 63 +++++ ...carer_and_severe_disability_additions.yaml | 3 + .../child_minimum_guarantee_addition.yaml | 24 ++ .../severe_disability_claimant_partner.yaml | 82 +++++++ .../is_pension_credit_eligible.yaml | 101 ++++++++ .../is_savings_credit_eligible.yaml | 23 +- .../carer_minimum_guarantee_addition.py | 5 +- .../child_minimum_guarantee_addition.py | 10 +- ...e_disability_minimum_guarantee_addition.py | 35 +-- .../is_pension_credit_eligible.py | 19 +- .../pension_credit/pension_credit_earnings.py | 19 +- .../pension_credit/pension_credit_income.py | 36 ++- .../is_savings_credit_eligible.py | 5 +- .../savings_credit/savings_credit_income.py | 17 +- 16 files changed, 612 insertions(+), 47 deletions(-) create mode 100644 changelog.d/pension-credit-claimant-partner.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_claimant_partner.yaml diff --git a/changelog.d/pension-credit-claimant-partner.fixed.md b/changelog.d/pension-credit-claimant-partner.fixed.md new file mode 100644 index 0000000000..903cef3c4c --- /dev/null +++ b/changelog.d/pension-credit-claimant-partner.fixed.md @@ -0,0 +1 @@ +- Applied Pension Credit age conditions, income assessment and carer and severe disability additions to claimants and partners rather than dependent children and young people, and removed the severe disability addition's child veto. Existing mixed-age transitional-protection and severe-disability couple/residence limitations remain. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml new file mode 100644 index 0000000000..dbf4bb5dbf --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -0,0 +1,216 @@ +- name: An explicit Pension Credit earnings input is the claimant and partner's earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + young_person: + age: 18 + is_in_non_advanced_education: true + employment_income: 3_000 + benunits: + benunit: + members: [claimant, young_person] + pension_credit_earnings: 10_000 + output: + # pension_credit_earnings counts only the claimant and partner (SPCA s.5), + # so an input of 10,000 is taken as theirs; the dependant's 3,000 is not + # added. + pension_credit_income: 10_000 + +- name: Pension Credit earnings exclude a qualifying young person's earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + employment_income: 5_000 + young_person: + age: 18 + is_in_non_advanced_education: true + employment_income: 3_000 + benunits: + benunit: + members: [claimant, young_person] + output: + # Only the claimant's 5,000 counts. + pension_credit_earnings: 5_000 + +- name: Pension Credit excludes an 18-year-old qualifying young person's income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + state_pension: 8_000 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + young_person: + age: 18 + is_in_non_advanced_education: true + employment_income: 3_000 + self_employment_income: 1_000 + property_income: 500 + benunits: + benunit: + members: [claimant, young_person] + working_tax_credit: 200 + pension_credit_deemed_income: 104 + output: + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_pension_credit: [false, true] + # SPCA 2002 s.5: 8,000 claimant pension + 200 unit tax credit + 104 + # unit tariff income = 8,304; the dependant's 4,500 income is excluded. + pension_credit_income: 8_304 + +- name: Pension Credit excludes a 19-year-old qualifying young person's deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + state_pension: 10_000 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + employment_income: 3_000 + income_tax: 100 + national_insurance: 100 + pension_contributions: 2_000 + benunits: + benunit: + members: [claimant, young_person] + output: + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_pension_credit: [false, true] + # SPCA 2002 s.5: only the claimant's 10,000 pension counts. Neither + # the dependant's 3,000 earnings nor 100 + 100 + 50% * 2,000 deductions count. + pension_credit_income: 10_000 + +- name: Pension Credit means-tested basic income is restricted to the claimant and partner + period: 2026 + absolute_error_margin: 0.01 + input: + gov.contrib.ubi_center.basic_income.interactions.include_in_means_tests: true + people: + claimant: + age: 75 + is_parent: true + state_pension: 8_000 + basic_income: 100 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + young_person: + age: 18 + is_in_non_advanced_education: true + basic_income: 1_000 + benunits: + benunit: + members: [claimant, young_person] + output: + # The reform elects to count basic income. SPCA 2002 s.5 limits whose + # income is counted: 8,000 pension + 100 claimant basic income = 8,100. + pension_credit_income: 8_100 + +- name: Pension Credit counts both partners' income deductions and carer additions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + state_pension: 5_000 + employment_income: 1_000 + jsa_contrib: 100 + income_tax: 100 + national_insurance: 0 + pension_contributions: 200 + is_carer_for_benefits: true + partner: + age: 75 + private_pension_income: 2_000 + self_employment_income: 500 + esa_contrib: 200 + income_tax: 50 + national_insurance: 25 + pension_contributions: 100 + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant, partner] + pension_credit_deemed_income: 104 + output: + is_claimant_or_partner: [true, true] + # SPCA 2002 s.5: gross = 5,000 + 1,000 + 100 + 2,000 + 500 + 200 + # + 104 = 8,904; deductions = 100 + 50 + 25 + 50% * 300 = 325. + pension_credit_income: 8_579 + # Reg.9: remove the claimant's 100 JSA and partner's 200 ESA. + savings_credit_income: 8_279 + # Sch.I para.4 and the 2026 carer addition: 2 * 48.15 * 52 = 5,007.60. + carer_minimum_guarantee_addition: 5_007.60 + +- name: A qualifying young person who is a carer does not receive a Pension Credit carer addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + is_carer_for_benefits: false + young_person: + age: 18 + is_in_non_advanced_education: true + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant, young_person] + output: + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_pension_credit: [false, true] + # Sch.I para.4 counts entitled claimants/partners: 0 * 48.15 * 52 = 0. + carer_minimum_guarantee_addition: 0 + +- name: Savings Credit does not subtract a dependant's excluded benefits from claimant income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + state_pension: 10_000 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + young_person: + age: 19 + is_in_non_advanced_education: true + esa_contrib: 200 + receives_benefits_in_own_right: true + benunits: + benunit: + members: [claimant, young_person] + output: + is_claimant_or_partner: [true, false] + # SPCA 2002 s.5 and reg.9: 10,000 claimant pension - 0 claimant/partner + # excluded benefits = 10,000. The dependant's 200 ESA never enters income. + pension_credit_income: 10_000 + savings_credit_income: 10_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml new file mode 100644 index 0000000000..b42bbe07ee --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml @@ -0,0 +1,63 @@ +# SPCA 2002 ss.2(5), 3 and SPC Regulations 2002 regs 6-7, Sch IIA para 10. +# These integration cases use the foundation's relation_type formula. +- name: Single pensioner with a qualifying young person receives single rates and Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + is_parent: true + private_pension_income: 12_000 + young_person: + age: 18 + is_in_non_advanced_education: true + benunits: + benunit: + members: [claimant, young_person] + output: + relation_type: SINGLE + is_pension_credit_eligible: true + is_savings_credit_eligible: true + # 238 * 52 = 12,376; eldest QYP born before 2017: 81.07 * 52 = 4,215.64. + standard_minimum_guarantee: 12_376 + child_minimum_guarantee_addition: 4_215.64 + minimum_guarantee: 16_591.64 + pension_credit_income: 12_000 + # Guarantee: 16,591.64 - 12,000 = 4,591.64. + guarantee_credit: 4_591.64 + # Savings: (12,000 - 208.07 * 52) * 0.6 = 708.216, below maximum 933.816. + savings_credit: 708.216 + pension_credit_entitlement: 5_299.856 + +- name: Pensioner couple with a qualifying young person retains couple rates and Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + is_parent: true + private_pension_income: 10_000 + partner: + age: 79 + private_pension_income: 8_000 + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + benunits: + benunit: + members: [claimant, partner, young_person] + output: + relation_type: COUPLE + is_pension_credit_eligible: true + is_savings_credit_eligible: true + # 363.25 * 52 = 18,889; child addition 81.07 * 52 = 4,215.64. + standard_minimum_guarantee: 18_889 + minimum_guarantee: 23_104.64 + pension_credit_income: 18_000 + guarantee_credit: 5_104.64 + # (18,000 - 329.75 * 52) * 0.6 = 511.80, below maximum 1,045.20. + savings_credit: 511.80 + pension_credit_entitlement: 5_616.44 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml index 577d4c653b..00319cb92c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml @@ -82,6 +82,9 @@ output: receives_carer_benefit: [false, false] is_carer_for_benefits: [false, true] + # Retained model limitation: Sch I para 1(1)(b)/(c) requires both partners + # qualifying, or one qualifying and one blind. This fixture's one-addition + # expectation preserves the existing simplification; it is not the legal award. severe_disability_minimum_guarantee_addition: 67.3 * 52 - name: Severe disability addition is withheld when the partner receives Carer's Allowance diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml index e51465b6e8..a99d4cbda2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml @@ -17,3 +17,27 @@ output: child_minimum_guarantee_addition: 7_854.6 minimum_guarantee: 20_230.6 + +- name: An eldest qualifying young person receives the higher child amount ahead of a younger child. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + is_parent: true + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + child: + age: 6 + birth_year: 2020 + benunits: + benunit: + members: [claimant, young_person, child] + output: + # Sch IIA para 10: eldest QYP gets 81.07, younger child 69.98 per week. + # (81.07 + 69.98) * 52 = 7,854.60; single guarantee = 238 * 52. + child_minimum_guarantee_addition: 7_854.60 + minimum_guarantee: 20_230.60 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_claimant_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_claimant_partner.yaml new file mode 100644 index 0000000000..b289708dd7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_claimant_partner.yaml @@ -0,0 +1,82 @@ +# SPC Regulations 2002 Sch I paras 1-2 and reg 6(5). +# Children and qualifying young people do not block the addition or receive +# a claimant/partner addition. The 2026 weekly single amount is GBP 86.05. +- name: A child without disability benefits does not block the claimant's addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 79 + attendance_allowance: 100 + child: + age: 10 + benunits: + benunit: + members: [claimant, child] + output: + # One qualifying claimant: 1 * 86.05 * 52 = 4,474.60. + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: An 18-year-old qualifying young person's PIP does not add a second claimant amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 79 + is_parent: true + attendance_allowance: 100 + young_person: + age: 18 + is_in_non_advanced_education: true + pip_dl: 100 + benunits: + benunit: + members: [claimant, young_person] + output: + # Only the claimant qualifies: 1 * 86.05 * 52 = 4,474.60. + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: Two qualifying partners with a disabled young person receive two claimant amounts. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 79 + is_parent: true + attendance_allowance: 100 + partner: + age: 70 + attendance_allowance: 100 + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + pip_dl: 100 + benunits: + benunit: + members: [claimant, partner, young_person] + output: + # Reg 6(5)(b): 2 * 86.05 * 52 = 8,949.20, excluding the dependant. + severe_disability_minimum_guarantee_addition: 8_949.20 + +- name: A dependant's disability benefit alone does not qualify the claimant. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 79 + is_parent: true + young_person: + age: 18 + is_in_non_advanced_education: true + pip_dl: 100 + benunits: + benunit: + members: [claimant, young_person] + output: + # No qualifying claimant: 0 * 86.05 * 52 = 0. + severe_disability_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml index d2fa9efdae..8940989fab 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml @@ -10,6 +10,107 @@ output: is_pension_credit_eligible: false +- name: Pensioner responsible for an 18-year-old student has one pension-age claimant. + period: 2026 + input: + people: + claimant: + age: 79 + is_parent: true + young_person: + age: 18 + is_in_non_advanced_education: true + benunits: + benunit: + members: [claimant, young_person] + output: + # SPCA ss.1(2)(b), 4(1A): 1 of 1 claimants is pension age. + is_claimant_or_partner: [true, false] + is_pension_credit_eligible: true + +- name: Pensioner responsible for a 19-year-old trainee has one pension-age claimant. + period: 2026 + input: + people: + claimant: + age: 79 + is_parent: true + young_person: + age: 19 + is_in_approved_training: true + age_started_or_accepted_current_education_or_training: 18 + benunits: + benunit: + members: [claimant, young_person] + output: + # SPCA ss.1(2)(b), 4(1A): 1 of 1 claimants is pension age. + is_claimant_or_partner: [true, false] + is_pension_credit_eligible: true + +- name: Pension-age couple with a 19-year-old student is eligible. + period: 2026 + input: + people: + claimant: + age: 79 + is_parent: true + partner: + age: 70 + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + benunits: + benunit: + members: [claimant, partner, young_person] + output: + # SPCA ss.1(2)(b), 4(1A): 2 of 2 claimants are pension age. + is_claimant_or_partner: [true, true, false] + is_pension_credit_eligible: true + +- name: A 19-year-old student partner is still a mixed-age couple. + period: 2026 + input: + people: + claimant: + age: 79 + partner: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + benunits: + benunit: + members: [claimant, partner] + output: + # No identified parent: 1 of 2 claimants is pension age, so s.4(1A) bars PC. + is_claimant_or_partner: [true, true] + is_pension_credit_eligible: false + +- name: An explicitly identified under-18 partner must meet Pension Credit age conditions. + period: 2026 + input: + people: + claimant: + age: 79 + partner: + age: 17 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + output: + # SPCA s.4(1A): 1 of 2 claimants is pension age; age-18 counting missed this partner. + is_pension_credit_eligible: false + +- name: Pension Credit needs a claimant even when every member is pension age. + period: 2026 + input: + age: 79 + is_claimant_or_partner: false + output: + # SPCA s.1: no claimant means no entitlement, even though 0 of 0 meet SPA. + is_pension_credit_eligible: false + - name: Case 2, single adult above State Pension age is eligible. period: 2026 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index 42ee1cbd29..8e32aebaa7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -24,7 +24,6 @@ benunits: benunit: members: [person] - relation_type: SINGLE savings_credit_income: 10200 output: is_savings_credit_eligible: true @@ -40,7 +39,6 @@ benunits: benunit: members: [person] - relation_type: SINGLE savings_credit_income: 10_000 output: is_savings_credit_eligible: false @@ -60,7 +58,6 @@ benunits: benunit: members: [person, spouse] - relation_type: COUPLE savings_credit_income: 16000 output: is_savings_credit_eligible: true @@ -80,7 +77,25 @@ benunits: benunit: members: [person, spouse] - relation_type: COUPLE savings_credit_income: 16000 output: is_savings_credit_eligible: false + +- name: A nonclaimant's age-requirement flag cannot establish Savings Credit eligibility. + period: 2026 + input: + people: + claimant: + age: 68 + young_person: + age: 19 + is_claimant_or_partner: false + # Deliberate override to isolate s.3(1)'s claimant/partner restriction. + meets_savings_credit_age_requirement: true + benunits: + benunit: + members: [claimant, young_person] + savings_credit_income: 12_000 + output: + # 12,000 > 208.07 * 52, but no claimant reached SPA before the cutoff. + is_savings_credit_eligible: false diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/carer_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/carer_minimum_guarantee_addition.py index e500ec8f78..b7c4b625d5 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/carer_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/carer_minimum_guarantee_addition.py @@ -10,7 +10,7 @@ class carer_minimum_guarantee_addition(Variable): reference = "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/4" documentation = ( - "Paid for each claimant entitled to Carer's Allowance, which includes " + "Paid for each claimant or partner entitled to Carer's Allowance, which includes " "underlying entitlement where the allowance is not paid (for example " "under the overlapping-benefit rule), so the test is is_carer_for_benefits " "rather than receipt." @@ -18,6 +18,7 @@ class carer_minimum_guarantee_addition(Variable): def formula(benunit, period, parameters): is_carer = benunit.members("is_carer_for_benefits", period) - num_carers = benunit.sum(is_carer) + is_claimant_or_partner = benunit.members("is_claimant_or_partner", period) + num_carers = benunit.sum(is_carer & is_claimant_or_partner) gc = parameters(period).gov.dwp.pension_credit.guarantee_credit return num_carers * gc.carer.addition * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py index 91af615384..8a6677bece 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py @@ -11,14 +11,14 @@ class child_minimum_guarantee_addition(Variable): def formula(benunit, period, parameters): person = benunit.members - is_child = person( + is_child_or_qualifying_young_person = person( "is_child_or_qualifying_young_person_for_pension_credit", period ) child_index = ( person.get_rank( person.benunit, -person("age", period), - condition=is_child, + condition=is_child_or_qualifying_young_person, ) + 1 ) @@ -40,9 +40,9 @@ def formula(benunit, period, parameters): per_child_amount = ( select( [ - is_child & is_not_disabled, - is_child & is_standard_disabled, - is_child & is_severely_disabled, + is_child_or_qualifying_young_person & is_not_disabled, + is_child_or_qualifying_young_person & is_standard_disabled, + is_child_or_qualifying_young_person & is_severely_disabled, ], [ child_addition, diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 2a95fd2cae..0cd16fd39e 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -7,34 +7,35 @@ class severe_disability_minimum_guarantee_addition(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/2", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + ) def formula(benunit, period, parameters): - # 1. At least one adult receives a qualifying benefit - # 2. No children (except children receiving qualifying benefits) - # 3. Nobody receives Carer's Allowance (technically 'for one of the - # claimants', but we assume this is true). This is receipt, not - # entitlement: the addition is withheld only where a carer's - # allowance is "entitled to and in receipt of" for the claimant, so - # a carer with underlying entitlement alone does not block it. + # Count qualifying claimants/partners; children and qualifying young + # people are ignored under Sch I para 2(2)(f). + # Retained simplifications: one addition per qualifying member, without + # para 1(1)(b)/(c)'s both-partners-or-blind-partner condition or the + # household-level non-dependant residence test. + # A carer benefit paid anywhere in the benefit unit is assumed to be + # for a claimant and blocks the award. Underlying entitlement alone + # does not block it; the person receiving care is not identified. severe_disability = parameters( period ).gov.dwp.pension_credit.guarantee_credit.severe_disability relevant_benefits = severe_disability.relevant_benefits person = benunit.members person_receives_qualifying_benefits = add(person, period, relevant_benefits) > 0 - is_adult = person("is_adult", period) - count_eligible_adults = benunit.sum( - is_adult & person_receives_qualifying_benefits - ) - any_children_without_benefits = ( - benunit.sum(~is_adult & ~person_receives_qualifying_benefits) > 0 + claimant_or_partner = person("is_claimant_or_partner", period) + count_eligible_claimants = benunit.sum( + claimant_or_partner & person_receives_qualifying_benefits ) carer_benefit_received = add(benunit, period, ["receives_carer_benefit"]) > 0 - eligible = ~any_children_without_benefits & ~carer_benefit_received return ( - eligible - * count_eligible_adults + ~carer_benefit_received + * count_eligible_claimants * severe_disability.addition * WEEKS_IN_YEAR ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py index d3c12d4372..ec51831654 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py @@ -7,18 +7,23 @@ class is_pension_credit_eligible(Variable): definition_period = YEAR value_type = bool unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + ) def formula(benunit, period, parameters): - adult = benunit.members("is_adult", period) - adult_count = benunit.sum(adult) - all_adults_are_sp_age = ( - benunit.sum(adult & benunit.members("is_SP_age", period)) == adult_count + claimant_or_partner = benunit.members("is_claimant_or_partner", period) + claimant_count = benunit.sum(claimant_or_partner) + all_claimants_are_sp_age = ( + benunit.sum(claimant_or_partner & benunit.members("is_SP_age", period)) + == claimant_count ) + # Mixed-age transitional protection under SI 2019/37 art. 4 is not modelled. is_gc_eligible = benunit("is_guarantee_credit_eligible", period) is_sc_eligible = benunit("is_savings_credit_eligible", period) return ( - (adult_count > 0) - & all_adults_are_sp_age + (claimant_count > 0) + & all_claimants_are_sp_age & (is_gc_eligible | is_sc_eligible) ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings.py index 9cf8040c3b..c4a6137b6a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings.py @@ -3,9 +3,24 @@ class pension_credit_earnings(Variable): label = "earnings for Pension Credit" + documentation = ( + "Earnings of the claimant and partner. A claimant's income includes " + "their partner's (State Pension Credit Act 2002 s.5), but not that of " + "children or young persons in the family." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A" - adds = "gov.dwp.pension_credit.guarantee_credit.earnings_sources" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + ) + + def formula(benunit, period, parameters): + sources = parameters( + period + ).gov.dwp.pension_credit.guarantee_credit.earnings_sources + person = benunit.members + earnings = add(person, period, sources) + return benunit.sum(earnings * person("is_claimant_or_partner", period)) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index df44d712f8..1160800f3a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -7,17 +7,43 @@ class pension_credit_income(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/15" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + ) + documentation = ( + "Income and deductions of the claimant and partner, together with " + "benefit-unit income sources. Dependants' income and deductions " + "are excluded." + ) def formula(benunit, period, parameters): pc = parameters(period).gov.dwp.pension_credit sources = pc.guarantee_credit.income - total = add(benunit, period, sources) + person = benunit.members + is_claimant_or_partner = person("is_claimant_or_partner", period) + total = 0 + for source in sources: + if person.entity.get_variable(source).entity.is_person: + total += benunit.sum(person(source, period) * is_claimant_or_partner) + else: + total += add(benunit, period, [source]) + + # Benefit-unit sources (pension_credit_earnings, which already counts + # only the claimant and partner, working tax credit and tariff income) + # are counted once. bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: - total += add(benunit, period, ["basic_income"]) - pension_contributions = add(benunit, period, ["pension_contributions"]) - tax = add(benunit, period, ["income_tax", "national_insurance"]) + total += benunit.sum( + person("basic_income", period) * is_claimant_or_partner + ) + pension_contributions = benunit.sum( + person("pension_contributions", period) * is_claimant_or_partner + ) + tax = benunit.sum( + add(person, period, ["income_tax", "national_insurance"]) + * is_claimant_or_partner + ) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate return max_(0, total - deductions) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py index 85ac297559..a7263e8b74 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py @@ -10,9 +10,10 @@ class is_savings_credit_eligible(Variable): reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" def formula(benunit, period, parameters): - # Check if any member reached SPA before the cutoff year + # The claimant or partner must have reached SPA before the cutoff year. has_pre_cutoff_spa_member = benunit.any( - benunit.members("meets_savings_credit_age_requirement", period) + benunit.members("is_claimant_or_partner", period) + & benunit.members("meets_savings_credit_age_requirement", period) ) income = benunit("savings_credit_income", period) sc = parameters(period).gov.dwp.pension_credit.savings_credit diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py index f62e21e744..856e7e99f1 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py @@ -3,15 +3,26 @@ class savings_credit_income(Variable): label = "Income for Savings Credit" - documentation = "Savings Credit (Pension Credit) excludes certain income sources from the calculation of the amount." + documentation = ( + "Savings Credit (Pension Credit) excludes certain income sources of " + "the claimant and partner from the calculation of the amount." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/9" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/9", + ) def formula(benunit, period, parameters): guarantee_credit_income = benunit("pension_credit_income", period) pc = parameters(period).gov.dwp.pension_credit - excluded_income = add(benunit, period, pc.savings_credit.excluded_income) + person = benunit.members + is_claimant_or_partner = person("is_claimant_or_partner", period) + excluded_income = benunit.sum( + add(person, period, pc.savings_credit.excluded_income) + * is_claimant_or_partner + ) return max_(0, guarantee_credit_income - excluded_income) From 44023f0adf29dcea7d9b1fb47ab0fdc879b6b2ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:44:00 -0400 Subject: [PATCH 08/24] Apply the legacy benefits' own child, young person and claimant definitions Income Support, income-based JSA, income-related ESA, Housing Benefit and council tax reduction now use the claimant and partner (SSCBA 1992 s.137; IS Regs 1987 reg 2; HB Regs 2006 reg 2) and a child under 16 or Child Benefit qualifying young person (IS reg 14; HB reg 19; JSA reg 76; ESA reg 2), instead of age-18 counts: personal allowance ages, lone-parent status and the youngest-child test, earnings disregards, disability premiums, HB childcare charges (reg 28 age limits) and the capital owner weights. Built with a gpt-6-astra subfleet lane. Co-Authored-By: Claude Opus 5.5 --- changelog.d/legacy-child-definitions.fixed.md | 1 + .../childcare/child_age_limit/disabled.yaml | 9 + .../childcare/child_age_limit/standard.yaml | 9 + .../amounts/age_threshold/older.yaml | 9 + .../amounts/age_threshold/younger.yaml | 9 + .../lone_parent_youngest_child_age_limit.yaml | 9 + .../family/esa_income/esa_income_capital.yaml | 3 +- .../family/esa_income/legacy_capital.yaml | 77 +++++ ...t_applicable_income_childcare_element.yaml | 237 ++++++++++++++- .../housing_benefit_applicable_amount.yaml | 116 +++---- .../housing_benefit_capital.yaml | 2 +- .../housing_benefit/legacy_capital.yaml | 52 ++++ .../legacy_claimant_relationships.yaml | 205 +++++++++++++ .../income_support_capital.yaml | 9 +- .../family/income_support/legacy_capital.yaml | 82 +++++ .../legacy_family_definitions.yaml | 286 ++++++++++++++++++ .../income_support/legacy_premiums.yaml | 168 ++++++++++ .../income_support/legacy_youngest_child.yaml | 95 ++++++ .../family/income_support_post_migration.yaml | 3 - .../family/jsa_income/jsa_income_capital.yaml | 2 +- .../family/jsa_income/legacy_capital.yaml | 77 +++++ .../legacy_claimant_non_dep_exemption.yaml | 103 +++++++ .../gov/dwp/esa_income_assessable_capital.py | 26 +- ...fit_applicable_income_childcare_element.py | 11 +- ...ing_benefit_applicable_income_disregard.py | 4 +- .../housing_benefit_applicable_amount.py | 3 +- .../housing_benefit_assessable_capital.py | 21 +- ..._children_for_housing_benefit_childcare.py | 38 +++ .../dwp/income_support_applicable_amount.py | 19 +- .../dwp/income_support_applicable_income.py | 10 +- .../dwp/income_support_assessable_capital.py | 32 +- .../gov/dwp/income_support_eligible.py | 8 +- .../gov/dwp/jsa_income_assessable_capital.py | 26 +- ...aimants_or_partners_for_legacy_benefits.py | 22 ++ ...aimants_or_partners_for_legacy_benefits.py | 23 ++ ...aimants_or_partners_for_legacy_benefits.py | 22 ++ .../youngest_child_age_for_legacy_benefits.py | 26 ++ ...council_tax_reduction_applicable_amount.py | 6 +- ...duction_household_has_non_dep_exemption.py | 5 +- .../demographic/disability_premium.py | 17 +- .../enhanced_disability_premium.py | 19 +- .../demographic/severe_disability_premium.py | 20 +- 42 files changed, 1750 insertions(+), 171 deletions(-) create mode 100644 changelog.d/legacy-child-definitions.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/disabled.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/standard.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/older.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/younger.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/eligibility/lone_parent_youngest_child_age_limit.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/legacy_capital.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_capital.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_claimant_relationships.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_capital.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_premiums.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_youngest_child.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/legacy_capital.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/num_relevant_children_for_housing_benefit_childcare.py create mode 100644 policyengine_uk/variables/gov/dwp/num_disabled_claimants_or_partners_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/num_severely_disabled_claimants_or_partners_for_legacy_benefits.py create mode 100644 policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py diff --git a/changelog.d/legacy-child-definitions.fixed.md b/changelog.d/legacy-child-definitions.fixed.md new file mode 100644 index 0000000000..8fce60860e --- /dev/null +++ b/changelog.d/legacy-child-definitions.fixed.md @@ -0,0 +1 @@ +- Updated legacy benefits to use claimant and partner roles and programme-specific child definitions for legacy benefit allowances, income disregards, capital allocation, disability premiums, Housing Benefit childcare charges, and Council Tax Reduction exemptions. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/disabled.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/disabled.yaml new file mode 100644 index 0000000000..bf1cfa9654 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/disabled.yaml @@ -0,0 +1,9 @@ +description: The exclusive annual age limit approximating the first Monday in September following the sixteenth birthday for Housing Benefit childcare charges for a disabled child. +values: + 2006-03-06: 17 +metadata: + unit: year + label: housing benefit childcare disabled child age limit + reference: + title: Housing Benefit Regulations 2006, regulation 28(6) and (13) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/28 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/standard.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/standard.yaml new file mode 100644 index 0000000000..ad5e7aef94 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/childcare/child_age_limit/standard.yaml @@ -0,0 +1,9 @@ +description: The exclusive annual age limit approximating the first Monday in September following the fifteenth birthday for Housing Benefit childcare charges. +values: + 2006-03-06: 16 +metadata: + unit: year + label: housing benefit childcare standard child age limit + reference: + title: Housing Benefit Regulations 2006, regulation 28(6) and (13) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/28 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/older.yaml b/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/older.yaml new file mode 100644 index 0000000000..3c274508fa --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/older.yaml @@ -0,0 +1,9 @@ +description: Income Support personal allowance age threshold for older single claimants. +values: + 1988-04-11: 25 +metadata: + unit: year + label: income support older allowance age threshold + reference: + - title: Income Support (General) Regulations 1987, Schedule 2 paragraph 1 + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/2 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/younger.yaml b/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/younger.yaml new file mode 100644 index 0000000000..73bbf7a1e3 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/amounts/age_threshold/younger.yaml @@ -0,0 +1,9 @@ +description: Income Support personal allowance age threshold for young claimants and partners. +values: + 1988-04-11: 18 +metadata: + unit: year + label: income support younger allowance age threshold + reference: + - title: Income Support (General) Regulations 1987, Schedule 2 paragraph 1 + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/2 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/eligibility/lone_parent_youngest_child_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/lone_parent_youngest_child_age_limit.yaml new file mode 100644 index 0000000000..9ea08c5cba --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/eligibility/lone_parent_youngest_child_age_limit.yaml @@ -0,0 +1,9 @@ +description: Income Support lone-parent youngest-child age limit, applied inclusively under the existing annual-age approximation although legislation specifies under this age. +values: + 2012-05-21: 5 +metadata: + unit: year + label: income support lone parent child age limit + reference: + - title: Income Support (General) Regulations 1987, Schedule 1B paragraph 1 + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index bdbbef68e0..c27645ce44 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -99,7 +99,7 @@ esa_income_eligible: false esa_income: 0 -- name: Income-related ESA allocates household capital by adult share when nobody reports the award +- name: Income-related ESA allocates household capital by claimant and partner share when nobody reports the award period: 2026 absolute_error_margin: 0 input: @@ -178,7 +178,6 @@ income_support_reported: 1 child: age: 4 - is_child: true benunits: benunit: members: [claimant, child] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/legacy_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/legacy_capital.yaml new file mode 100644 index 0000000000..beed6513c8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/legacy_capital.yaml @@ -0,0 +1,77 @@ +# https://www.legislation.gov.uk/uksi/2008/794/regulation/83 +# Only claimant and partner contribute capital weights. The equal allocation of +# household stocks is a PolicyEngine convention, not a statutory allocation rule. +- name: Income-related ESA capital allocation includes a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # Two claimants: each gets 18,000 * 1 / 2 = 9,000. + # Previously the under-18 claimant had zero weight: [0, 18,000]. + esa_income_assessable_capital: [9_000, 9_000] + +- name: Income-related ESA capital allocation excludes a dependent student aged 19 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_parent: true + student: + age: 19 + is_in_non_advanced_education: true + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant, student] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, student, unrelated_claimant] + savings: 18_000 + output: + # The dependent student adds no weight. Each claimant gets 18,000 / 2. + # Previously the student's unit got 18,000 * 2 / 3 = 12,000 (other: 6,000). + esa_income_assessable_capital: [9_000, 9_000] + +- name: Income-related ESA claim path recognises a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + esa_income_reported: 1_000 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # The model's claim-path convention assigns all 18,000 to the claimant + # and zero to the non-claiming unit. Previously the age-18 gate yielded + # fallback allocation [0, 18,000] despite this claimant's claim path. + esa_income_assessable_capital: [18_000, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml index c6aa7c77a9..411dd36183 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml @@ -1,27 +1,242 @@ - name: One child, capped period: 2025 - absolute_error_margin: 100 + absolute_error_margin: 0.01 input: - benunit_count_children: 1 - childcare_expenses: 9_000 + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 10 + benunits: + benunit: + members: [claimant, child0] output: - housing_benefit_applicable_income_childcare_element: 9_000 + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 1 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9000 - name: Two child, capped period: 2025 - absolute_error_margin: 100 + absolute_error_margin: 0.01 input: - benunit_count_children: 2 - childcare_expenses: 10_000 + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 10000 + child0: + age: 8 + child1: + age: 10 + benunits: + benunit: + members: [claimant, child0, child1] output: - housing_benefit_applicable_income_childcare_element: 10_000 + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 2 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 2 + housing_benefit_applicable_income_childcare_element: 10000 - name: No children period: 2025 - absolute_error_margin: 100 + absolute_error_margin: 0.01 input: - benunit_count_children: 0 - childcare_expenses: 30_000 + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 30000 + benunits: + benunit: + members: [claimant] output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 0 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 +- name: Non-disabled child aged 15 remains within annual age approximation + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 15 + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 1 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9000 + +- name: Non-disabled young person aged 16 no longer counts + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 16 + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 0 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 0 + housing_benefit_applicable_income_childcare_element: 0 + +- name: Non-disabled young person aged 17 no longer counts + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 17 + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 0 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 0 + housing_benefit_applicable_income_childcare_element: 0 + +- name: Disabled young person aged 16 counts + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 16 + is_disabled_for_benefits: true + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 1 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9000 + +- name: Blind young person aged 16 counts + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 16 + is_blind: true + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 1 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9000 + +- name: Disabled young person aged 17 no longer counts + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9000 + child0: + age: 17 + is_disabled_for_benefits: true + benunits: + benunit: + members: [claimant, child0] + output: + # Annual age limits are <16, or <17 if disabled/blind (HB reg 28(6)). + # With 0 relevant children the deduction is min(cost, weekly cap * 52). + num_relevant_children_for_housing_benefit_childcare: 0 + housing_benefit_applicable_income_childcare_element: 0 + +- name: One relevant child and an older young person use the one-child cap + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 30_000 + child: + age: 10 + young_person: + age: 17 + benunits: + benunit: + members: [claimant, child, young_person] + output: + num_relevant_children_for_housing_benefit_childcare: 1 + # Reg 27(3) selects the one-child cap: min(30,000, 312.0555942 * 52). + # The existing uprated WTC cap is retained; its difference from the + # statutory HB cap of 175/week is outside this classification change. + housing_benefit_applicable_income_childcare_element: 16_226.8908984 + +- name: Under-18 claimant is not their own relevant child + period: 2025 + input: + people: + claimant: + age: 17 + childcare_expenses: 9_000 + benunits: + benunit: + members: [claimant] + output: + # 0 dependants implies 0 childcare cap (HB reg 27(3)). + num_relevant_children_for_housing_benefit_childcare: 0 + housing_benefit_applicable_income_childcare_element: 0 + +- name: Disabled 16-year-old outside education and training is not a relevant young person + period: 2025 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9_000 + young_person: + age: 16 + is_disabled_for_benefits: true + is_in_non_advanced_education: false + is_in_approved_training: false + benunits: + benunit: + members: [claimant, young_person] + output: + # HB reg 28(6) requires a child of the family, including a qualifying + # young person; the disability age extension does not create that status. + num_relevant_children_for_housing_benefit_childcare: 0 + housing_benefit_applicable_income_childcare_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_applicable_amount.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_applicable_amount.yaml index b1b03bc4bd..eeea8a6e58 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_applicable_amount.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_applicable_amount.yaml @@ -2,23 +2,15 @@ period: 2025 absolute_error_margin: 100 input: - people: - person1: - is_SP_age: false - person2: - is_SP_age: false - person3: - is_SP_age: false - person4: + people: + claimant: + age: 25 is_SP_age: false + is_claimant_or_partner: true benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant] housing_benefit_eligible: true - eldest_adult_age: 25 - is_single_person: true - is_couple: false - is_lone_parent: false benefits_premiums: 0 output: housing_benefit_applicable_amount: 90.5 * 52 @@ -27,23 +19,15 @@ period: 2025 absolute_error_margin: 100 input: - people: - person1: - is_SP_age: false - person2: - is_SP_age: false - person3: - is_SP_age: false - person4: + people: + claimant: + age: 23 is_SP_age: false + is_claimant_or_partner: true benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant] housing_benefit_eligible: true - eldest_adult_age: 23 - is_single_person: true - is_couple: false - is_lone_parent: false benefits_premiums: 0 output: housing_benefit_applicable_amount: 71.7 * 52 @@ -52,23 +36,19 @@ period: 2025 absolute_error_margin: 100 input: - people: - person1: - is_SP_age: false - person2: + people: + claimant: + age: 17 is_SP_age: false - person3: - is_SP_age: false - person4: + is_claimant_or_partner: true + is_parent: true + child: + age: 1 is_SP_age: false benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant, child] housing_benefit_eligible: true - eldest_adult_age: 17 - is_single_person: false - is_couple: false - is_lone_parent: true benefits_premiums: 0 output: housing_benefit_applicable_amount: 71.7 * 52 @@ -77,23 +57,19 @@ period: 2025 absolute_error_margin: 100 input: - people: - person1: - is_SP_age: false - person2: + people: + claimant: + age: 18 is_SP_age: false - person3: - is_SP_age: false - person4: + is_claimant_or_partner: true + is_parent: true + child: + age: 1 is_SP_age: false benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant, child] housing_benefit_eligible: true - eldest_adult_age: 18 - is_single_person: false - is_couple: false - is_lone_parent: true benefits_premiums: 0 output: housing_benefit_applicable_amount: 90.5 * 52 @@ -102,23 +78,19 @@ period: 2025 absolute_error_margin: 200 input: - people: - person1: - is_SP_age: false - person2: + people: + claimant: + age: 18 is_SP_age: false - person3: - is_SP_age: false - person4: + is_claimant_or_partner: true + partner: + age: 18 is_SP_age: false + is_claimant_or_partner: true benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant, partner] housing_benefit_eligible: true - eldest_adult_age: 18 - is_single_person: false - is_couple: true - is_lone_parent: false benefits_premiums: 0 output: housing_benefit_applicable_amount: 142.25 * 52 @@ -127,23 +99,19 @@ period: 2025 absolute_error_margin: 100 input: - people: - person1: - is_SP_age: false - person2: - is_SP_age: false - person3: + people: + claimant: + age: 17 is_SP_age: false - person4: + is_claimant_or_partner: true + partner: + age: 17 is_SP_age: false + is_claimant_or_partner: true benunits: benunit: - members: [person1, person2, person3, person4] + members: [claimant, partner] housing_benefit_eligible: true - eldest_adult_age: 17 - is_single_person: false - is_couple: true - is_lone_parent: false benefits_premiums: 0 output: housing_benefit_applicable_amount: 108.3 * 52 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml index e9a0cd47c3..f598331f3a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml @@ -129,7 +129,7 @@ housing_benefit_assessable_capital: 50_000 housing_benefit_eligible: false -- name: Mixed benunits split household Housing Benefit capital by adult share +- name: Mixed benunits split household Housing Benefit capital by claimant and partner share period: 2025 absolute_error_margin: 0 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_capital.yaml new file mode 100644 index 0000000000..b1ff157058 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_capital.yaml @@ -0,0 +1,52 @@ +# https://www.legislation.gov.uk/uksi/2006/213/regulation/25 +# Only claimant and partner contribute capital weights. The equal allocation of +# household stocks is a PolicyEngine convention, not a statutory allocation rule. +- name: Housing Benefit capital allocation includes a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # Two claimants: each gets 18,000 * 1 / 2 = 9,000. + # Previously the under-18 claimant had zero weight: [0, 18,000]. + housing_benefit_assessable_capital: [9_000, 9_000] + +- name: Housing Benefit capital allocation excludes a dependent student aged 19 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_parent: true + student: + age: 19 + is_in_non_advanced_education: true + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant, student] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, student, unrelated_claimant] + savings: 18_000 + output: + # The dependent student adds no weight. Each claimant gets 18,000 / 2. + # Previously the student's unit got 18,000 * 2 / 3 = 12,000 (other: 6,000). + housing_benefit_assessable_capital: [9_000, 9_000] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_claimant_relationships.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_claimant_relationships.yaml new file mode 100644 index 0000000000..278930ea01 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/legacy_claimant_relationships.yaml @@ -0,0 +1,205 @@ +- name: Lone parent with qualifying young person uses lone-parent rates + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 40 + weekly_hours: 0 + is_claimant_or_partner: true + is_parent: true + person1: + age: 18 + weekly_hours: 0 + is_in_non_advanced_education: true + benunits: + benunit: + members: [person0, person1] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 92.04; *52. + housing_benefit_applicable_amount: 4786.08 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 4786.08 + # HB Sch 4 paras 4/7/10: 25 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 1300 + +- name: Couple with qualifying young person uses one couple allowance + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 40 + weekly_hours: 0 + is_claimant_or_partner: true + is_parent: true + person1: + age: 38 + weekly_hours: 0 + is_claimant_or_partner: true + person2: + age: 18 + weekly_hours: 0 + is_in_non_advanced_education: true + benunits: + benunit: + members: [person0, person1, person2] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 144.67; *52. + housing_benefit_applicable_amount: 7522.84 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 7522.84 + # HB Sch 4 paras 4/7/10: 10 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 520 + +- name: Under-18 single claimant uses single rates + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 17 + weekly_hours: 0 + is_claimant_or_partner: true + benunits: + benunit: + members: [person0] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 72.92; *52. + housing_benefit_applicable_amount: 3791.84 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 3791.84 + # HB Sch 4 paras 4/7/10: 5 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 260 + +- name: Couple with under-18 partner uses couple rates + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 30 + weekly_hours: 0 + is_claimant_or_partner: true + person1: + age: 17 + weekly_hours: 0 + is_claimant_or_partner: true + benunits: + benunit: + members: [person0, person1] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 144.67; *52. + housing_benefit_applicable_amount: 7522.84 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 7522.84 + # HB Sch 4 paras 4/7/10: 10 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 520 + +- name: Lone under-18 parent uses younger lone-parent allowance + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 17 + weekly_hours: 0 + is_claimant_or_partner: true + is_parent: true + person1: + age: 1 + weekly_hours: 0 + benunits: + benunit: + members: [person0, person1] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 72.92; *52. + housing_benefit_applicable_amount: 3791.84 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 3791.84 + # HB Sch 4 paras 4/7/10: 25 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 1300 + +- name: Two under-18 claimants use younger couple allowance + period: 2025 + absolute_error_margin: 0.01 + input: + # Isolate the statutory Sch 4 categories from existing automatic uprating. + gov.dwp.housing_benefit.means_test.income_disregard.single: 5 + gov.dwp.housing_benefit.means_test.income_disregard.couple: 10 + gov.dwp.housing_benefit.means_test.income_disregard.lone_parent: 25 + people: + person0: + age: 17 + weekly_hours: 0 + is_claimant_or_partner: true + person1: + age: 16 + weekly_hours: 0 + is_claimant_or_partner: true + benunits: + benunit: + members: [person0, person1] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1: the applicable weekly model rate is 110.14; *52. + housing_benefit_applicable_amount: 5727.28 + # CTR retains the existing shared HB allowance parameters. + council_tax_reduction_applicable_amount: 5727.28 + # HB Sch 4 paras 4/7/10: 10 * 52, with no hours disregard. + housing_benefit_applicable_income_disregard: 520 + +- name: Under-18 claimant allowance ignores an older qualifying dependant + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 17 + is_claimant_or_partner: true + qualifying_dependant: + age: 18 + is_claimant_or_partner: false + is_in_non_advanced_education: true + benunits: + benunit: + members: [claimant, qualifying_dependant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + # HB Sch 3 para 1(2): the claimant is under 18, even if responsible + # for an older qualifying young person. Younger rate 72.92 * 52. + housing_benefit_applicable_amount: 3_791.84 + # The shared CTR allowance calculation also uses only claimant/partner ages. + council_tax_reduction_applicable_amount: 3_791.84 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml index 585884ea9c..932f83c9d1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml @@ -8,7 +8,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -35,7 +34,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -57,7 +55,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -79,7 +76,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -103,7 +99,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -125,7 +120,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -138,7 +132,7 @@ income_support_eligible: false income_support: 0 -- name: Income Support allocates household capital by adult share across benunits +- name: Income Support allocates household capital by claimant and partner share across benunits period: 2021 absolute_error_margin: 1 input: @@ -221,7 +215,6 @@ income_support_reported: true c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_capital.yaml new file mode 100644 index 0000000000..e3f70982ef --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_capital.yaml @@ -0,0 +1,82 @@ +# https://www.legislation.gov.uk/uksi/1987/1967/regulation/23 +# Only claimant and partner contribute capital weights. The equal allocation of +# household stocks is a PolicyEngine convention, not a statutory allocation rule. +- name: Income Support capital allocation includes a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + would_claim_IS: false + unrelated_benunit: + members: [unrelated_claimant] + would_claim_IS: false + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # Two claimants: each gets 18,000 * 1 / 2 = 9,000. + # Previously the under-18 claimant had zero weight: [0, 18,000]. + income_support_assessable_capital: [9_000, 9_000] + +- name: Income Support capital allocation excludes a dependent student aged 19 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_parent: true + student: + age: 19 + is_in_non_advanced_education: true + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant, student] + would_claim_IS: false + unrelated_benunit: + members: [unrelated_claimant] + would_claim_IS: false + households: + household: + members: [claimant, student, unrelated_claimant] + savings: 18_000 + output: + # The dependent student adds no weight. Each claimant gets 18,000 / 2. + # Previously the student's unit got 18,000 * 2 / 3 = 12,000 (other: 6,000). + income_support_assessable_capital: [9_000, 9_000] + +- name: Income Support claim path recognises a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + would_claim_IS: true + unrelated_benunit: + members: [unrelated_claimant] + would_claim_IS: false + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # The model's claim-path convention assigns all 18,000 to the claimant + # and zero to the non-claiming unit. Previously the age-18 gate yielded + # fallback allocation [0, 18,000] despite this claimant's claim path. + income_support_assessable_capital: [18_000, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml new file mode 100644 index 0000000000..00ec78032a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml @@ -0,0 +1,286 @@ +# IS Regulations 1987 Sch 2 para 1; Sch 8 paras 5, 6 and 10. +# Annual amounts use existing CPI-uprated 2025 weekly parameters * 52: +# low = 80.01221579759817 * 52 = 4160.635221475105; +# high = 101.00013997540619 * 52 = 5252.007278721122; +# couple = 158.66598990083986 * 52 = 8250.631474843673. +# Applicable incomes: 10000 - 52 * (single 5 / lone parent 20 / couple 10) +# = 9740 / 8960 / 9480. Tax, premiums, other income held at zero. +# Retained limits: mixed 18-24/under18 incorrectly uses the higher gap amount +# (Sch 2 para 1(3)(f)); young couples' higher own-right/child branches are not +# modelled (para 1(3)(a),(e)). These rate-policy issues are outside this change. +- name: IS single claimant aged 17 is not their own child + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 17 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + benunits: + family: + members: + - claimant + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 4160.635221475105 + income_support_applicable_income: 9740 +- name: IS lone parent aged 17 uses the under-18 allowance + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 17 + is_parent: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + child: + age: 1 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - child + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 4160.635221475105 + income_support_applicable_income: 8960 +- name: IS lone parent aged 20 with qualifying young person uses parent allowance + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 20 + is_parent: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + young_person: + age: 18 + is_in_non_advanced_education: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - young_person + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 5252.007278721122 + income_support_applicable_income: 8960 +- name: IS 17-year-old outside education or training is not a legacy young person + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 20 + is_parent: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + young_person: + age: 17 + is_in_non_advanced_education: false + is_in_approved_training: false + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - young_person + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 4160.635221475105 + income_support_applicable_income: 9740 +- name: IS couple with under-18 partner uses retained mixed-age allowance + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 30 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - partner + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 5252.007278721122 + income_support_applicable_income: 9480 +- name: IS couple aged 24 and 17 retains existing mixed-age approximation + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 24 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - partner + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 5252.007278721122 + income_support_applicable_income: 9480 +- name: IS couple both under 18 uses retained young-couple allowance + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 17 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - partner + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 4160.635221475105 + income_support_applicable_income: 9480 +- name: IS couple with qualifying 19-year-old gets only couple disregard + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 40 + is_parent: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + employment_income: 10000 + partner: + age: 40 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + young_person: + age: 19 + is_in_non_advanced_education: true + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + - partner + - young_person + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 8250.631474843673 + income_support_applicable_income: 9480 +- name: IS single claimant age 24 and 25 allowance boundary + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 24 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 4160.635221475105 +- name: IS single claimant aged 25 uses older allowance + period: 2025 + absolute_error_margin: 0.02 + input: + people: + claimant: + age: 25 + income_tax: 0 + national_insurance: 0 + social_security_income: 0 + pension_contributions: 0 + benunits: + family: + members: + - claimant + benefits_premiums: 0 + income_support_tariff_income: 0 + output: + income_support_applicable_amount: 5252.007278721122 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_premiums.yaml new file mode 100644 index 0000000000..141319853e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_premiums.yaml @@ -0,0 +1,168 @@ +# IS Schedule 2 paragraphs 11-13A and HB Schedule 3 paragraphs 12-15 +# distinguish the claimant and partner from their children and young persons. +# Annual amounts use the 2026 weekly disability-premia parameters times 52: +# single: disability 44.85 * 52 = 2332.20; enhanced 22 * 52 = 1144; +# severe 86.05 * 52 = 4474.60. +# couple: disability 64 * 52 = 3328; enhanced 31.40 * 52 = 1632.80; +# severe 172.10 * 52 = 8949.20. + +- name: Disabled 16-year-old claimant receives the single legacy premiums + period: 2026 + absolute_error_margin: 0.01 + input: + age: 16 + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + output: + # The claimant qualifies in their own right: no statutory age-18 floor. + disability_premium: 2332.20 + enhanced_disability_premium: 1144 + severe_disability_premium: 4474.60 + +- name: Disabled 17-year-old claimant receives the single legacy premiums + period: 2026 + absolute_error_margin: 0.01 + input: + age: 17 + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + output: + disability_premium: 2332.20 + enhanced_disability_premium: 1144 + severe_disability_premium: 4474.60 + +- name: Disabled 18-year-old young person does not qualify a parent for legacy premiums + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 18 + is_in_non_advanced_education: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + benunits: + family: + members: [parent, young_person] + output: + # Zero qualifying claimants/partners, so each claimant premium is zero. + # The separate disabled-child premium is outside the current model. + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + +- name: Disabled 19-year-old young person does not qualify a parent for legacy premiums + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 19 + is_in_non_advanced_education: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + benunits: + family: + members: [parent, young_person] + output: + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + +- name: Disabled 17-year-old partner qualifies the couple for legacy premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + benunits: + family: + members: [claimant, partner] + output: + disability_premium: 3328 + enhanced_disability_premium: 1632.80 + # Deliberately preserve the existing severe-premium approximation: + # one qualifying partner currently triggers the full couple rate. + # IS Sch 2 para 13 / HB Sch 3 para 14 require both to qualify. + severe_disability_premium: 8949.20 + +- name: Disabled lone parent with a young person keeps single legacy premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + young_person: + age: 19 + is_in_non_advanced_education: true + benunits: + family: + members: [parent, young_person] + output: + disability_premium: 2332.20 + enhanced_disability_premium: 1144 + severe_disability_premium: 4474.60 + +- name: Disabled child does not qualify a parent for legacy claimant premiums + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 15 + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + output: + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + +- name: Two qualifying partners receive one couple rate for each legacy premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + partner: + age: 32 + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + benunits: + family: + members: [claimant, partner] + output: + disability_premium: 3328 + enhanced_disability_premium: 1632.80 + severe_disability_premium: 8949.20 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_youngest_child.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_youngest_child.yaml new file mode 100644 index 0000000000..b6bbe5369a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_youngest_child.yaml @@ -0,0 +1,95 @@ +# SSCBA 1992 ss.137(1),142(1): children are under 16, excluding the claimant +# and partner. IS Sch 1B para 1 says under 5; the model deliberately retains +# <= 5 pending the separate threshold decision requested in the brief. +- name: IS youngest child excludes a young claimant and qualifying young person + period: 2025 + input: + people: + claimant: + age: 17 + is_parent: true + is_benunit_head: true + income_support_reported: 1000 + child: + age: 4 + young_person: + age: 18 + is_in_non_advanced_education: true + benunits: + family: + members: [claimant, child, young_person] + esa_income: 0 + income_support_assessable_capital: 0 + output: + youngest_child_age_for_legacy_benefits: 4 + income_support_eligible: true + +- name: IS youngest child threshold retains age five and excludes age six + period: 2025 + input: + people: + claimant1: + age: 30 + income_support_reported: 1000 + child1: + age: 5 + claimant2: + age: 30 + income_support_reported: 1000 + child2: + age: 6 + benunits: + family1: + members: [claimant1, child1] + esa_income: 0 + income_support_assessable_capital: 0 + family2: + members: [claimant2, child2] + esa_income: 0 + income_support_assessable_capital: 0 + output: + youngest_child_age_for_legacy_benefits: [5, 6] + income_support_eligible: [true, false] + +- name: IS young person alone does not satisfy young-child eligibility + period: 2025 + input: + people: + claimant: + age: 30 + is_parent: true + income_support_reported: 1000 + young_person: + age: 16 + is_in_non_advanced_education: true + benunits: + family: + members: [claimant, young_person] + esa_income: 0 + income_support_assessable_capital: 0 + output: + is_lone_parent: true + income_support_eligible: false + +- name: IS claimant with under-18 partner is not a lone parent + period: 2025 + input: + people: + claimant: + age: 30 + is_parent: true + income_support_reported: 1000 + partner: + age: 17 + is_parent: true + child: + age: 3 + benunits: + family: + members: [claimant, partner, child] + esa_income: 0 + income_support_assessable_capital: 0 + output: + youngest_child_age_for_legacy_benefits: 3 + is_lone_parent: false + income_support_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support_post_migration.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support_post_migration.yaml index 0223228b7a..cc897fc829 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support_post_migration.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support_post_migration.yaml @@ -8,7 +8,6 @@ income_support_reported: 3000 c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -30,7 +29,6 @@ income_support_reported: 3000 c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] @@ -52,7 +50,6 @@ income_support_reported: 3000 c1: age: 4 - is_child: true benunits: b1: members: [p1, c1] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml index 13ecc3d659..024036709e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml @@ -98,7 +98,7 @@ jsa_income_eligible: false jsa_income: 0 -- name: Income-based JSA allocates household capital by adult share when nobody reports the award +- name: Income-based JSA allocates household capital by claimant and partner share when nobody reports the award period: 2026 absolute_error_margin: 0 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/legacy_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/legacy_capital.yaml new file mode 100644 index 0000000000..58fb386941 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/legacy_capital.yaml @@ -0,0 +1,77 @@ +# https://www.legislation.gov.uk/uksi/1996/207/regulation/88 +# Only claimant and partner contribute capital weights. The equal allocation of +# household stocks is a PolicyEngine convention, not a statutory allocation rule. +- name: Income-based JSA capital allocation includes a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # Two claimants: each gets 18,000 * 1 / 2 = 9,000. + # Previously the under-18 claimant had zero weight: [0, 18,000]. + jsa_income_assessable_capital: [9_000, 9_000] + +- name: Income-based JSA capital allocation excludes a dependent student aged 19 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_parent: true + student: + age: 19 + is_in_non_advanced_education: true + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant, student] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, student, unrelated_claimant] + savings: 18_000 + output: + # The dependent student adds no weight. Each claimant gets 18,000 / 2. + # Previously the student's unit got 18,000 * 2 / 3 = 12,000 (other: 6,000). + jsa_income_assessable_capital: [9_000, 9_000] + +- name: Income-based JSA claim path recognises a claimant aged 17 + period: 2021 + absolute_error_margin: 0 + input: + people: + claimant: + age: 17 + jsa_income_reported: 1_000 + unrelated_claimant: + age: 30 + benunits: + claimant_benunit: + members: [claimant] + unrelated_benunit: + members: [unrelated_claimant] + households: + household: + members: [claimant, unrelated_claimant] + savings: 18_000 + output: + # The model's claim-path convention assigns all 18,000 to the claimant + # and zero to the non-claiming unit. Previously the age-18 gate yielded + # fallback allocation [0, 18,000] despite this claimant's claim path. + jsa_income_assessable_capital: [18_000, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml new file mode 100644 index 0000000000..ec6636d1fa --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml @@ -0,0 +1,103 @@ +- name: Dependent young person PIP does not exempt the claimant household + period: 2025 + input: + people: + claimant: + age: 70 + is_parent: true + other_member: + age: 18 + is_in_non_advanced_education: true + pip_dl: 100 + non_dependant: + age: 25 + benunits: + claimant_unit: + members: [claimant, other_member] + other_unit: + members: [non_dependant] + households: + household: + members: [claimant, other_member, non_dependant] + output: + # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. + council_tax_reduction_household_has_non_dep_exemption: false + +- name: Under-18 partner PIP exempts the claimant household + period: 2025 + input: + people: + claimant: + age: 70 + is_claimant_or_partner: true + is_parent: true + other_member: + age: 17 + is_in_non_advanced_education: true + is_claimant_or_partner: true + pip_dl: 100 + non_dependant: + age: 25 + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant, other_member] + other_unit: + members: [non_dependant] + households: + household: + members: [claimant, other_member, non_dependant] + output: + # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. + is_couple: [true, false] + council_tax_reduction_household_has_non_dep_exemption: true + +- name: Claimant PIP continues to exempt the household + period: 2025 + input: + people: + claimant: + age: 70 + is_parent: true + pip_dl: 100 + other_member: + age: 18 + is_in_non_advanced_education: true + non_dependant: + age: 25 + benunits: + claimant_unit: + members: [claimant, other_member] + other_unit: + members: [non_dependant] + households: + household: + members: [claimant, other_member, non_dependant] + output: + # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. + council_tax_reduction_household_has_non_dep_exemption: true + +- name: Dependent young person blindness does not exempt the claimant household + period: 2025 + input: + people: + claimant: + age: 70 + is_parent: true + other_member: + age: 18 + is_in_non_advanced_education: true + is_blind: true + non_dependant: + age: 25 + benunits: + claimant_unit: + members: [claimant, other_member] + other_unit: + members: [non_dependant] + households: + household: + members: [claimant, other_member, non_dependant] + output: + # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. + council_tax_reduction_household_has_non_dep_exemption: false diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 473bcdfebb..bbb79786d0 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -10,12 +10,14 @@ class esa_income_assessable_capital(Variable): "ESA capital test. Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-related ESA award and only falls back to " - "an adult-share proxy when nobody in the household is on that reported " - "claim path." + "a claimant-and-partner share when nobody in the household is on that " + "reported claim path. This allocation is a PolicyEngine convention; " + "the data cannot identify ownership of capital." ) definition_period = YEAR unit = GBP quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2008/794/regulation/83" def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income @@ -26,16 +28,20 @@ def formula(benunit, period, parameters): household_capital = sum( benunit.max(person.household(source, period)) for source in sources ) - benunit_adults = add(benunit, period, ["is_adult"]) + # Regulation 83(2) excludes children's and young persons' capital. + # The claimant/partner weights approximate otherwise unobserved ownership. + benunit_claimants_and_partners = add( + benunit, period, ["is_claimant_or_partner"] + ) household_reporting_claimants = benunit.max( - person.household.sum( - person("is_adult", period) & (person("esa_income_reported", period) > 0) - ) + person.household.sum(person("esa_income_reported", period) > 0) ) - household_adults = benunit.max( - person.household.sum(person.household.members("is_adult", period)) + household_claimants_and_partners = benunit.max( + person.household.sum(person("is_claimant_or_partner", period)) ) - fallback_divisor = max_(1, household_adults) + fallback_divisor = max_(1, household_claimants_and_partners) claiming_proxy = where(claiming_esa_income, household_capital, 0) - fallback_proxy = household_capital * benunit_adults / fallback_divisor + fallback_proxy = ( + household_capital * benunit_claimants_and_partners / fallback_divisor + ) return where(household_reporting_claimants > 0, claiming_proxy, fallback_proxy) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.py index 287304c8ad..aa7dd6bd65 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.py @@ -7,16 +7,19 @@ class housing_benefit_applicable_income_childcare_element(Variable): label = "Housing Benefit applicable income childcare element" definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/27" def formula(benunit, period, parameters): p = parameters(period).gov.dwp.tax_credits.working_tax_credit.elements - num_children = benunit("benunit_count_children", period) - childcare_amount_1 = (num_children == 1) * p.childcare_1 - childcare_amount_2 = (num_children > 1) * p.childcare_2 + relevant_children = benunit( + "num_relevant_children_for_housing_benefit_childcare", period + ) + childcare_amount_1 = (relevant_children == 1) * p.childcare_1 + childcare_amount_2 = (relevant_children > 1) * p.childcare_2 max_weekly_childcare_amount = childcare_amount_1 + childcare_amount_2 # Assuming that no children leads to no childcare element capped_max_childcare_amount = where( - num_children == 0, 0, max_weekly_childcare_amount + relevant_children == 0, 0, max_weekly_childcare_amount ) max_childcare_amount = capped_max_childcare_amount * WEEKS_IN_YEAR childcare_expenses = add(benunit, period, ["childcare_expenses"]) 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 1722ced06b..e5381c7c5a 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 @@ -7,12 +7,14 @@ class housing_benefit_applicable_income_disregard(Variable): label = "Housing Benefit applicable income disregards" definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2006/213/schedule/4" def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard hours = add(benunit, period, ["weekly_hours"]) - # Calculate single, couple, lone parent, and worker disregards. + # The claimant categories are mutually exclusive (Sch 4 paras 4, 7, 10). + # Worker disregard is additional. single = benunit("is_single_person", period) single_disregard = single * p.single couple = benunit("is_couple", period) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py index 50c086675b..e5e84f8d97 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py @@ -8,11 +8,12 @@ class housing_benefit_applicable_amount(Variable): definition_period = YEAR unit = GBP defined_for = "housing_benefit_eligible" + reference = "https://www.legislation.gov.uk/uksi/2006/213/schedule/3" def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.allowances any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) - eldest_age = benunit("eldest_adult_age", period) + eldest_age = benunit("eldest_claimant_or_partner_age", period) older_age_threshold = p.age_threshold.older younger_age_threshold = p.age_threshold.younger u_18 = eldest_age < younger_age_threshold diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index c978c5f699..9f6f4d7947 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -7,11 +7,14 @@ class housing_benefit_assessable_capital(Variable): label = "Housing Benefit assessable capital" documentation = ( "Housing Benefit capital counted from the configured capital sources, " - "allocated across benunits using a household adult-share proxy." + "allocated across benunits in proportion to their claimants and partners. " + "This is a PolicyEngine convention because household capital data " + "cannot identify ownership; children and young persons add no weight." ) definition_period = YEAR unit = GBP quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/25" def formula(benunit, period, parameters): household = benunit.household @@ -19,14 +22,18 @@ def formula(benunit, period, parameters): any_over_SP_age = benunit.any(person("is_SP_age", period)) p = parameters(period).gov.dwp.housing_benefit.means_test.capital household_capital = sum(household(source, period) for source in p.sources) - benunit_adults = add(benunit, period, ["is_adult"]) - household_adults = benunit.max( - person.household.sum(person.household.members("is_adult", period)) + benunit_claimants_and_partners = add( + benunit, period, ["is_claimant_or_partner"] ) - adult_divisor = max_(1, household_adults) + household_claimants_and_partners = benunit.max( + person.household.sum(person("is_claimant_or_partner", period)) + ) + claimant_partner_divisor = max_(1, household_claimants_and_partners) household_capital_proxy = where( - household_adults > 0, - household_capital * benunit_adults / adult_divisor, + household_claimants_and_partners > 0, + household_capital + * benunit_claimants_and_partners + / claimant_partner_divisor, 0, ) guarantee_credit = any_over_SP_age & (benunit("guarantee_credit", period) > 0) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/num_relevant_children_for_housing_benefit_childcare.py b/policyengine_uk/variables/gov/dwp/housing_benefit/num_relevant_children_for_housing_benefit_childcare.py new file mode 100644 index 0000000000..b678bac9f0 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/num_relevant_children_for_housing_benefit_childcare.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class num_relevant_children_for_housing_benefit_childcare(Variable): + """Children whose ages permit relevant Housing Benefit childcare charges. + + Regulation 28(6) ends eligibility on the day before the first Monday + in September after the fifteenth birthday (sixteenth if disabled). + Annual integer ages approximate this with exclusive limits of 16 and + 17. The legacy-benefit family definition excludes claimants/partners. + Per-child charges are unobserved, so eligible children stand in for + children in respect of whom charges are paid under regulation 27(3). + Disability uses the existing benefits-disability input or blindness; + historic certification and suspended-payment cases are unobserved. + """ + + value_type = int + entity = BenUnit + label = "Relevant children for Housing Benefit childcare charges" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/27", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/28", + ) + + def formula(benunit, period, parameters): + person = benunit.members + p = parameters( + period + ).gov.dwp.housing_benefit.means_test.childcare.child_age_limit + child_or_young_person = person( + "is_child_or_young_person_for_legacy_benefits", period + ) + disabled = person("is_disabled_for_benefits", period) | person( + "is_blind", period + ) + age_limit = where(disabled, p.disabled, p.standard) + return benunit.sum(child_or_young_person & (person("age", period) < age_limit)) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_amount.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_amount.py index 9c8733abf5..a560722817 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_amount.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_amount.py @@ -7,16 +7,19 @@ class income_support_applicable_amount(Variable): label = "Applicable amount of Income Support" definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2" def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support amounts = IS.amounts - younger_age = benunit("youngest_adult_age", period) - older_age = benunit("eldest_adult_age", period) - younger_under_18 = younger_age < 18 - younger_under_25 = younger_age < 25 - older_under_18 = older_age < 18 - has_children = benunit.any(benunit.members("is_child", period)) + younger_age = benunit("youngest_claimant_or_partner_age", period) + older_age = benunit("eldest_claimant_or_partner_age", period) + younger_under_18 = younger_age < amounts.age_threshold.younger + younger_under_25 = younger_age < amounts.age_threshold.older + older_under_18 = older_age < amounts.age_threshold.younger + has_children = benunit( + "is_responsible_for_child_or_young_person_for_legacy_benefits", period + ) single = benunit("is_single", period) single_under_25 = single & ~has_children & younger_under_25 single_over_25 = single & ~has_children & ~younger_under_25 @@ -25,6 +28,10 @@ def formula(benunit, period, parameters): couple_young = ~single & older_under_18 couple_mixed = ~single & ~older_under_18 & younger_under_18 couple_old = ~single & ~younger_under_18 + # Retain the existing simplified couple rates. Schedule 2 para 1(3) + # has higher child/own-right qualification branches (a), (e), and a + # lower rate for an 18-24 claimant with an under-18 partner (f). + # Those distinctions require a separate allowance-policy change. personal_allowance_weekly = select( [ single_under_25, diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 5f6909cfb8..9f20ae70e8 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -7,6 +7,7 @@ class income_support_applicable_income(Variable): label = "Relevant income for Income Support means test" definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8" def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support @@ -29,17 +30,14 @@ def formula(benunit, period, parameters): income += benunit("income_support_tariff_income", period) income -= tax income -= add(benunit, period, ["pension_contributions"]) * 0.5 - family_type = benunit("family_type", period) - families = family_type.possible_values - # Calculate income disregards for each family type. + # Schedule 8 paras 5, 6 and 10 use mutually exclusive claimant types. mt = IS.means_test - single = family_type == families.SINGLE + single = benunit("is_single_person", period) income_disregard_single = single * mt.income_disregard_single - single = family_type == families.SINGLE income_disregard_couple = ( benunit("is_couple", period) * mt.income_disregard_couple ) - lone_parent = family_type == families.LONE_PARENT + lone_parent = benunit("is_lone_parent", period) income_disregard_lone_parent = lone_parent * mt.income_disregard_lone_parent income_disregard = ( income_disregard_single diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index e497c670f8..481efe02f8 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -9,12 +9,14 @@ class income_support_assessable_capital(Variable): "Household capital apportioned to the benefit unit for the Income Support " "capital test. Because the dataset only stores these stocks at household " "level, the model allocates full household capital to any benunit on the " - "IS claim path and only falls back to an adult-share proxy when nobody in " - "the household is on that path." + "IS claim path and only falls back to a claimant-and-partner share when " + "nobody in the household is on that path. This allocation is a " + "PolicyEngine convention; the data cannot identify ownership of capital." ) definition_period = YEAR unit = GBP quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/1987/1967/regulation/23" def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support @@ -25,21 +27,27 @@ def formula(benunit, period, parameters): # The data model stores these capital stocks at household level. For the # live Income Support path, avoid diluting capital across separate claims: # any benunit on the IS claim path gets the full observed household total. - # If nobody in the household is on that path, fall back to an all-adults - # proxy so direct inspection still returns a usable value. + # If nobody in the household is on that path, use claimant/partner + # weights. Regulation 23 excludes children's and young persons' capital; + # the household allocation itself is a modelling convention. household_capital = sum( benunit.max(person.household(source, period)) for source in sources ) - benunit_adults = add(benunit, period, ["is_adult"]) - household_claiming_adults = benunit.max( + benunit_claimants_and_partners = add( + benunit, period, ["is_claimant_or_partner"] + ) + household_claiming_members = benunit.max( person.household.sum( - person("is_adult", period) & person.benunit("would_claim_IS", period) + person("is_claimant_or_partner", period) + & person.benunit("would_claim_IS", period) ) ) - household_adults = benunit.max( - person.household.sum(person.household.members("is_adult", period)) + household_claimants_and_partners = benunit.max( + person.household.sum(person("is_claimant_or_partner", period)) ) - fallback_divisor = max_(1, household_adults) + fallback_divisor = max_(1, household_claimants_and_partners) claiming_proxy = where(would_claim_is, household_capital, 0) - fallback_proxy = household_capital * benunit_adults / fallback_divisor - return where(household_claiming_adults > 0, claiming_proxy, fallback_proxy) + fallback_proxy = ( + household_capital * benunit_claimants_and_partners / fallback_divisor + ) + return where(household_claiming_members > 0, claiming_proxy, fallback_proxy) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 0dc992a5bb..c0f6715ffa 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -6,10 +6,16 @@ class income_support_eligible(Variable): entity = BenUnit label = "Whether eligible for Income Support" definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B" def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - youngest_child_5_or_under = benunit("youngest_child_age", period) <= 5 + # Schedule 1B para 1 says "under 5". Retain the existing inclusive + # comparison pending a separate decision on the annual-age model. + youngest_child_5_or_under = ( + benunit("youngest_child_age_for_legacy_benefits", period) + <= IS.eligibility.lone_parent_youngest_child_age_limit + ) lone_parent = benunit("is_lone_parent", period) lone_parent_with_young_child = lone_parent & youngest_child_5_or_under has_carers = add(benunit, period, ["is_carer_for_benefits"]) > 0 diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index 2483cc9945..e436348b13 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -10,12 +10,14 @@ class jsa_income_assessable_capital(Variable): "JSA capital test. Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-based JSA award and only falls back to " - "an adult-share proxy when nobody in the household is on that reported " - "claim path." + "a claimant-and-partner share when nobody in the household is on that " + "reported claim path. This allocation is a PolicyEngine convention; " + "the data cannot identify ownership of capital." ) definition_period = YEAR unit = GBP quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/1996/207/regulation/88" def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA.income @@ -26,16 +28,20 @@ def formula(benunit, period, parameters): household_capital = sum( benunit.max(person.household(source, period)) for source in sources ) - benunit_adults = add(benunit, period, ["is_adult"]) + # Regulation 88(2) excludes children's and young persons' capital. + # The claimant/partner weights approximate otherwise unobserved ownership. + benunit_claimants_and_partners = add( + benunit, period, ["is_claimant_or_partner"] + ) household_reporting_claimants = benunit.max( - person.household.sum( - person("is_adult", period) & (person("jsa_income_reported", period) > 0) - ) + person.household.sum(person("jsa_income_reported", period) > 0) ) - household_adults = benunit.max( - person.household.sum(person.household.members("is_adult", period)) + household_claimants_and_partners = benunit.max( + person.household.sum(person("is_claimant_or_partner", period)) ) - fallback_divisor = max_(1, household_adults) + fallback_divisor = max_(1, household_claimants_and_partners) claiming_proxy = where(claiming_jsa_income, household_capital, 0) - fallback_proxy = household_capital * benunit_adults / fallback_divisor + fallback_proxy = ( + household_capital * benunit_claimants_and_partners / fallback_divisor + ) return where(household_reporting_claimants > 0, claiming_proxy, fallback_proxy) diff --git a/policyengine_uk/variables/gov/dwp/num_disabled_claimants_or_partners_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/num_disabled_claimants_or_partners_for_legacy_benefits.py new file mode 100644 index 0000000000..4635524208 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/num_disabled_claimants_or_partners_for_legacy_benefits.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class num_disabled_claimants_or_partners_for_legacy_benefits(Variable): + value_type = int + entity = BenUnit + label = "Disabled claimants or partners for legacy benefits" + documentation = ( + "Claimants and partners satisfying the model's existing " + "is_disabled_for_benefits indicator. Children and young persons " + "do not qualify a claimant for this premium through that indicator." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/11", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/12", + ) + + def formula(benunit, period, parameters): + claimant_or_partner = benunit.members("is_claimant_or_partner", period) + disabled = benunit.members("is_disabled_for_benefits", period) + return benunit.sum(claimant_or_partner & disabled) diff --git a/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py new file mode 100644 index 0000000000..6b7428f74e --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class num_enhanced_disabled_claimants_or_partners_for_legacy_benefits(Variable): + value_type = int + entity = BenUnit + label = "Enhanced-disabled claimants or partners for legacy benefits" + documentation = ( + "Claimants and partners satisfying the model's existing " + "is_enhanced_disabled_for_benefits indicator. This count excludes " + "children and young persons, whose separate enhanced premium is " + "not modelled here." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + ) + + def formula(benunit, period, parameters): + claimant_or_partner = benunit.members("is_claimant_or_partner", period) + disabled = benunit.members("is_enhanced_disabled_for_benefits", period) + return benunit.sum(claimant_or_partner & disabled) diff --git a/policyengine_uk/variables/gov/dwp/num_severely_disabled_claimants_or_partners_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/num_severely_disabled_claimants_or_partners_for_legacy_benefits.py new file mode 100644 index 0000000000..2d6f4987ad --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/num_severely_disabled_claimants_or_partners_for_legacy_benefits.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class num_severely_disabled_claimants_or_partners_for_legacy_benefits(Variable): + value_type = int + entity = BenUnit + label = "Severely disabled claimants or partners for legacy benefits" + documentation = ( + "Claimants and partners satisfying the model's existing " + "is_severely_disabled_for_benefits indicator. This count does not " + "implement the separate statutory non-dependant or carer conditions." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + ) + + def formula(benunit, period, parameters): + claimant_or_partner = benunit.members("is_claimant_or_partner", period) + disabled = benunit.members("is_severely_disabled_for_benefits", period) + return benunit.sum(claimant_or_partner & disabled) diff --git a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py new file mode 100644 index 0000000000..de2c1449f3 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class youngest_child_age_for_legacy_benefits(Variable): + value_type = float + entity = BenUnit + label = "Age of youngest child for legacy means-tested benefits" + documentation = ( + "Youngest family member under 16, excluding the claimant and partner. " + "SSCBA sections 137(1) and 142(1) use the same child age definition. " + "Infinity if the benefit unit contains no such child." + ) + definition_period = YEAR + unit = "year" + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + "https://www.legislation.gov.uk/ukpga/1992/4/section/142", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", + ) + + def formula(benunit, period, parameters): + person = benunit.members + child = person("is_child_for_child_benefit", period) & ~person( + "is_claimant_or_partner", period + ) + return benunit.min(where(child, person("age", period), np.inf)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index c096429589..8d64c045ab 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -7,11 +7,15 @@ class council_tax_reduction_applicable_amount(Variable): label = "applicable Council Tax Reduction amount" definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1", + ) def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.allowances any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) - eldest_age = benunit("eldest_adult_age", period) + eldest_age = benunit("eldest_claimant_or_partner_age", period) older_age_threshold = p.age_threshold.older younger_age_threshold = p.age_threshold.younger u_18 = eldest_age < younger_age_threshold diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py index 759aa5d7cc..3ede96b204 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -6,11 +6,14 @@ class council_tax_reduction_household_has_non_dep_exemption(Variable): entity = Household label = "CTR household has a non-dependant deduction exemption" definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8" def formula(household, period, parameters): person = household.members claimant_benunit = person.benunit("benunit_contains_household_head", period) - claimant_or_partner = claimant_benunit & person("is_adult", period) + claimant_or_partner = claimant_benunit & person( + "is_claimant_or_partner", period + ) is_blind = person("is_blind", period) & claimant_or_partner attendance_allowance = ( person("attendance_allowance", period) > 0 diff --git a/policyengine_uk/variables/household/demographic/disability_premium.py b/policyengine_uk/variables/household/demographic/disability_premium.py index e26f49195a..f151798fac 100644 --- a/policyengine_uk/variables/household/demographic/disability_premium.py +++ b/policyengine_uk/variables/household/demographic/disability_premium.py @@ -5,9 +5,17 @@ class disability_premium(Variable): value_type = float entity = BenUnit label = "Disability premium" + documentation = ( + "Legacy benefit disability premium for the claimant or partner, using " + "the model's existing disability indicator. The separate disabled " + "child premium is not modelled here." + ) definition_period = YEAR reference = ( - "The Social Security Amendment (Enhanced Disability Premium) Regulations 2000" + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/11", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/12", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/12", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/13", ) unit = GBP @@ -17,6 +25,9 @@ def formula(benunit, period, parameters): couple = benunit("is_couple", period.this_year) single_premium = single * dis.disability_single couple_premium = couple * dis.disability_couple - has_disabled_adults = benunit("num_disabled_adults", period.this_year) > 0 + has_disabled_claimant_or_partner = ( + benunit("num_disabled_claimants_or_partners_for_legacy_benefits", period) + > 0 + ) weekly_amount = single_premium + couple_premium - return weekly_amount * WEEKS_IN_YEAR * has_disabled_adults + return weekly_amount * WEEKS_IN_YEAR * has_disabled_claimant_or_partner diff --git a/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py b/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py index c2f8d7c4fd..296ab7b49e 100644 --- a/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py @@ -5,9 +5,16 @@ class enhanced_disability_premium(Variable): value_type = float entity = BenUnit label = "Enhanced disability premium" + documentation = ( + "Legacy benefit enhanced disability premium for the claimant or " + "partner, using the model's existing enhanced-disability indicator. " + "The separate Housing Benefit enhanced rate for children and young " + "persons is not modelled here." + ) definition_period = YEAR reference = ( - "The Social Security Amendment (Enhanced Disability Premium) Regulations 2000" + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", ) unit = GBP @@ -17,8 +24,12 @@ def formula(benunit, period, parameters): couple = benunit("is_couple", period.this_year) single_premium = single * dis.enhanced_single couple_premium = couple * dis.enhanced_couple - has_enhanced_disabled_adults = ( - benunit("num_enhanced_disabled_adults", period.this_year) > 0 + has_enhanced_disabled_claimant_or_partner = ( + benunit( + "num_enhanced_disabled_claimants_or_partners_for_legacy_benefits", + period, + ) + > 0 ) weekly_amount = single_premium + couple_premium - return weekly_amount * WEEKS_IN_YEAR * has_enhanced_disabled_adults + return weekly_amount * WEEKS_IN_YEAR * has_enhanced_disabled_claimant_or_partner diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index c8501f4e11..d2de6c640c 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -5,9 +5,17 @@ class severe_disability_premium(Variable): value_type = float entity = BenUnit label = "Severe disability premium" + documentation = ( + "Legacy benefit severe disability premium using the model's existing " + "severe-disability indicator for the claimant or partner. The current " + "approximation pays the couple rate when either partner qualifies; " + "the statutory requirement for both partners to qualify and the " + "non-dependant and carer exclusions remain unmodelled." + ) definition_period = YEAR reference = ( - "The Social Security Amendment (Enhanced Disability Premium) Regulations 2000" + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", ) unit = GBP @@ -17,8 +25,12 @@ def formula(benunit, period, parameters): couple = benunit("is_couple", period.this_year) single_premium = single * dis.severe_single couple_premium = couple * dis.severe_couple - has_severely_disabled_adults = ( - benunit("num_severely_disabled_adults", period.this_year) > 0 + has_severely_disabled_claimant_or_partner = ( + benunit( + "num_severely_disabled_claimants_or_partners_for_legacy_benefits", + period, + ) + > 0 ) weekly_amount = single_premium + couple_premium - return weekly_amount * WEEKS_IN_YEAR * has_severely_disabled_adults + return weekly_amount * WEEKS_IN_YEAR * has_severely_disabled_claimant_or_partner From 34cc129cb93402a8414a87ebfc439849ec2fdedf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:52:16 -0400 Subject: [PATCH 09/24] Decide the shared accommodation rate and benefit cap on the claimant's own circumstances The benefit cap's single rate applies to a claimant with no partner who is not responsible for a child or qualifying young person (UC Regs 2013 reg 80A; HB Regs 2006 reg 75CA), and the LHA shared accommodation rate to a single renter under 35 with no child or qualifying young person and no non-dependant (UC Regs Sch 4 paras 27-29; HB reg 13D), instead of household counts of people aged 18+. Built with a gpt-6-astra subfleet lane. Co-Authored-By: Claude Opus 5.5 --- ...-benefit-cap-claimant-definitions.fixed.md | 1 + .../family/LHA_claimant_composition.yaml | 222 ++++++++++++++ .../policy/baseline/gov/dwp/benefit_cap.yaml | 285 ++++++++++++++++++ .../variables/gov/dwp/LHA_category.py | 13 +- .../variables/gov/dwp/benefit_cap.py | 6 +- .../is_benefit_cap_single_claimant_rate.py | 29 ++ ...red_accommodation_rate_specified_renter.py | 40 +++ .../gov/dwp/lha_renter_has_non_dependant.py | 32 ++ 8 files changed, 613 insertions(+), 15 deletions(-) create mode 100644 changelog.d/lha-benefit-cap-claimant-definitions.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml create mode 100644 policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py create mode 100644 policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py create mode 100644 policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py diff --git a/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md new file mode 100644 index 0000000000..a02ad3ff96 --- /dev/null +++ b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md @@ -0,0 +1 @@ +- Replaced age-18 child/adult flags in LHA shared accommodation and benefit-cap rates with claimant/partner status and Universal Credit child responsibility, retaining the conservative LHA non-dependant proxy and documenting the shared Housing Benefit/Universal Credit modelling limits. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml new file mode 100644 index 0000000000..bcff19ad50 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml @@ -0,0 +1,222 @@ +# UC Regulations 2013, Schedule 4 paragraphs 9 and 27-28: +# https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/28 +# The existing bedroom calculation is unchanged: B = 1 bedroom, C = 2; +# A is the shared accommodation rate, overriding the bedroom calculation. +- name: Single 17-year-old renter is not their own dependent child + period: 2025 + input: + age: 17 + output: + is_claimant_or_partner: true + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + lha_renter_has_non_dependant: false + is_lha_shared_accommodation_rate_specified_renter: true + # Single; 17 < 35; no dependant or non-dependant: A (previously B). + LHA_category: A + +- name: Single 30-year-old renter remains subject to the shared rate + period: 2025 + input: + age: 30 + output: + lha_renter_has_non_dependant: false + is_lha_shared_accommodation_rate_specified_renter: true + # Single; 30 < 35; no other occupants: A. + LHA_category: A + +- name: Single renter immediately below the shared rate age threshold + period: 2025 + input: + age: 34 + output: + is_lha_shared_accommodation_rate_specified_renter: true + # 34 < 35: A. + LHA_category: A + +- name: Single renter at the shared rate age threshold + period: 2025 + input: + age: 35 + output: + is_lha_shared_accommodation_rate_specified_renter: false + # 35 is not below 35; 1 bedroom: B. + LHA_category: B + +- name: A 17-year-old apprentice in the renter's benefit unit is a non-dependant + period: 2025 + input: + people: + renter: + age: 30 + is_parent: true + apprentice: + age: 17 + current_education: NOT_IN_EDUCATION + is_apprentice: true + benunits: + family: + members: [renter, apprentice] + households: + home: + members: [renter, apprentice] + output: + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + lha_renter_has_non_dependant: true + is_lha_shared_accommodation_rate_specified_renter: false + # No UC QYP, but a non-dependant blocks A. Existing bedroom formula = 1: B. + LHA_category: B + +- name: Lone parent with an 18-year-old qualifying young person + period: 2025 + input: + people: + renter: + age: 30 + is_parent: true + student: + age: 18 + current_education: POST_SECONDARY + benunits: + family: + members: [renter, student] + households: + home: + members: [renter, student] + output: + is_couple: false + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + lha_renter_has_non_dependant: false + is_lha_shared_accommodation_rate_specified_renter: false + # Responsibility for a UC QYP blocks A. Existing bedroom formula = 1: B. + LHA_category: B + +- name: Couple aged 30 and 17 is not a single specified renter + period: 2025 + input: + people: + renter: + age: 30 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + benunits: + couple: + members: [renter, partner] + households: + home: + members: [renter, partner] + output: + is_couple: true + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + lha_renter_has_non_dependant: false + is_lha_shared_accommodation_rate_specified_renter: false + # A partner establishes neither QYP responsibility nor a non-dependant; + # couple room = 1: B under the existing bedroom calculation. + LHA_category: B + +- name: A 17-year-old claimant in another benefit unit blocks the shared rate + period: 2025 + input: + people: + renter: + age: 30 + apprentice: + age: 17 + current_education: NOT_IN_EDUCATION + is_apprentice: true + benunits: + renter_unit: + members: [renter] + apprentice_unit: + members: [apprentice] + households: + home: + members: [renter, apprentice] + output: + lha_renter_has_non_dependant: [true, true] + is_lha_shared_accommodation_rate_specified_renter: [false, false] + # Other unit's claimant blocks A; 1 + 1 occupant aged 16+ outside = 2: C. + # Renter previously A because the other claimant was under 18. + LHA_category: [C, C] + +- name: A 17-year-old student claimant in another unit also blocks the shared rate + period: 2025 + input: + people: + renter: + age: 30 + student_claimant: + age: 17 + current_education: POST_SECONDARY + benunits: + renter_unit: + members: [renter] + student_unit: + members: [student_claimant] + households: + home: + members: [renter, student_claimant] + output: + lha_renter_has_non_dependant: [true, true] + # Other claimant blocks A even if their age/education meets the QYP test. + # 1 + 1 occupant aged 16+ outside = 2: C (renter previously A). + LHA_category: [C, C] + +- name: Rent liability of another benefit unit does not exempt its claimant + period: 2025 + input: + people: + renter: + age: 30 + other_renter: + age: 40 + benunits: + renter_unit: + members: [renter] + benunit_rent: 12000 + other_unit: + members: [other_renter] + benunit_rent: 12000 + households: + home: + members: [renter, other_renter] + output: + eldest_claimant_or_partner_age: [30, 40] + lha_renter_has_non_dependant: [true, true] + # Conservative proxy retained: 1 + 1 occupant aged 16+ outside = 2: C. + LHA_category: [C, C] + +- name: Occupants in a different household do not block the shared rate + period: 2025 + input: + people: + renter: + age: 30 + other_renter: + age: 40 + benunits: + renter_unit: + members: [renter] + other_unit: + members: [other_renter] + households: + first_home: + members: [renter] + second_home: + members: [other_renter] + output: + lha_renter_has_non_dependant: [false, false] + # Single, 30 < 35, nobody else in this household: A. + # Other renter 40 >= 35; 1 bedroom: B. + LHA_category: [A, B] + +- name: Actual shared accommodation retains its separate route to category A + period: 2025 + input: + age: 40 + is_shared_accommodation: true + output: + is_lha_shared_accommodation_rate_specified_renter: false + # 40 >= 35, but actual shared accommodation independently gives A. + LHA_category: A diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml new file mode 100644 index 0000000000..3e269a5164 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml @@ -0,0 +1,285 @@ +# UC Regulations 2013, reg 80A(2), and HB Regulations 2006, reg 75CA(2): +# annual single / family caps are 14,753 / 22,020 outside London and +# 16,967 / 25,323 in London. These cases isolate rate selection from +# exemptions and do not assert eligibility to claim UC or HB. + +- name: Single claimant aged 30 receives the outside-London single cap. + period: 2025 + input: + age: 30 + region: SOUTH_EAST + is_benefit_cap_exempt: false + output: + is_benefit_cap_single_claimant_rate: true + benefit_cap: 14_753 + +- name: Single claimant aged 30 receives the London single cap. + period: 2025 + input: + age: 30 + region: LONDON + is_benefit_cap_exempt: false + output: + is_benefit_cap_single_claimant_rate: true + benefit_cap: 16_967 + +- name: Single claimant aged 17 does not count as their own child outside London. + period: 2025 + input: + age: 17 + is_benunit_head: true + region: SOUTH_EAST + is_benefit_cap_exempt: false + output: + is_claimant_or_partner: true + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # Old age counts selected 22,020; no partner or dependant means 14,753. + # Change = 14,753 - 22,020 = -7,267 per year. + benefit_cap: 14_753 + +- name: Single claimant aged 17 does not count as their own child in London. + period: 2025 + input: + age: 17 + is_benunit_head: true + region: LONDON + is_benefit_cap_exempt: false + output: + is_benefit_cap_single_claimant_rate: true + # London change = 16,967 - 25,323 = -8,356 per year. + benefit_cap: 16_967 + +- name: A 17-year-old apprentice in the claimant's unit does not give the family cap. + period: 2025 + input: + people: + claimant: + age: 30 + is_benunit_head: true + is_parent: true + apprentice: + age: 17 + is_benunit_head: false + is_apprentice: true + current_education: POST_SECONDARY + benunits: + family: + members: [claimant, apprentice] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, apprentice] + region: SOUTH_EAST + output: + is_claimant_or_partner: [true, false] + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # UC reg 5(3) excludes education under an employment contract. + # Single cap: 14,753, down 22,020 - 14,753 = 7,267 from age-only counts. + benefit_cap: 14_753 + +- name: A 17-year-old outside education or approved training does not give the family cap. + period: 2025 + input: + people: + claimant: + age: 30 + is_benunit_head: true + is_parent: true + young_person: + age: 17 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + is_in_approved_training: false + benunits: + family: + members: [claimant, young_person] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, young_person] + region: SOUTH_EAST + output: + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # UC reg 5(1)(b): no qualifying education/training after the age-16 route. + # Change = 14,753 - 22,020 = -7,267 per year. + benefit_cap: 14_753 + +- name: A 17-year-old receiving benefits in their own right does not give the family cap. + period: 2025 + input: + people: + claimant: + age: 30 + is_benunit_head: true + is_parent: true + young_person: + age: 17 + is_benunit_head: false + current_education: POST_SECONDARY + receives_benefits_in_own_right: true + benunits: + family: + members: [claimant, young_person] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, young_person] + region: SOUTH_EAST + output: + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # UC reg 5(5) excludes own-right UC/ESA/JSA recipients from QYP status. + # Change = 14,753 - 22,020 = -7,267 per year. + benefit_cap: 14_753 + +- name: A looked-after foster child does not give the claimant the family cap. + period: 2025 + input: + people: + claimant: + age: 30 + is_benunit_head: true + child: + age: 10 + is_benunit_head: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [claimant, child] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, child] + region: SOUTH_EAST + output: + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # UC reg 4(6)(a): this foster child is excluded from responsibility. + # Assume no respite or adoption placement exception under reg 4A. + # Change = 14,753 - 22,020 = -7,267 per year. + benefit_cap: 14_753 + +- name: Parent of a 17-year-old qualifying student keeps the family cap. + period: 2025 + input: + people: + parent: + age: 30 + is_benunit_head: true + is_parent: true + student: + age: 17 + is_benunit_head: false + current_education: POST_SECONDARY + benunits: + family: + members: [parent, student] + is_benefit_cap_exempt: false + households: + home: + members: [parent, student] + region: SOUTH_EAST + output: + is_couple: false + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + is_benefit_cap_single_claimant_rate: false + # Responsibility under UC regs 4-5 gives the 22,020 annual family limit. + benefit_cap: 22_020 + +- name: Lone parent of an 18-year-old qualifying student keeps the London family cap. + period: 2025 + input: + people: + parent: + age: 30 + is_benunit_head: true + is_parent: true + student: + age: 18 + is_benunit_head: false + current_education: POST_SECONDARY + benunits: + family: + members: [parent, student] + is_benefit_cap_exempt: false + households: + home: + members: [parent, student] + region: LONDON + output: + is_claimant_or_partner: [true, false] + is_couple: false + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + is_benefit_cap_single_claimant_rate: false + # Same 25,323 as before, now because of responsibility rather than two 18+ members. + benefit_cap: 25_323 + +- name: Couple aged 30 and 17 receives the family cap without counting the partner as a child. + period: 2025 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + partner: + age: 17 + is_claimant_or_partner: true + current_education: POST_SECONDARY + benunits: + family: + members: [claimant, partner] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, partner] + region: SOUTH_EAST + output: + is_couple: true + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: false + # Couple composition selects 22,020. This is not a UC eligibility test: + # UC reg 3(3) permits some couples to claim as singles, not modelled here. + benefit_cap: 22_020 + +- name: Explicitly identified 19-year-old non-dependant does not imply a partner. + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + non_dependant: + age: 19 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [claimant, non_dependant] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, non_dependant] + region: SOUTH_EAST + output: + is_couple: false + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false + is_benefit_cap_single_claimant_rate: true + # Old two-adult count gave 22,020. With explicit roles, no partner or QYP: + # change = 14,753 - 22,020 = -7,267 per year. + benefit_cap: 14_753 + +- name: Benefit-cap exemption still overrides the selected single rate. + period: 2025 + input: + age: 30 + region: SOUTH_EAST + is_benefit_cap_exempt: true + universal_credit_pre_benefit_cap: 100_000 + output: + is_benefit_cap_single_claimant_rate: true + # Exemption makes the cap infinite: max(100,000 - infinity, 0) = 0. + # Assert the resulting reduction because the YAML runner cannot compare infinities. + benefit_cap_reduction: 0 diff --git a/policyengine_uk/variables/gov/dwp/LHA_category.py b/policyengine_uk/variables/gov/dwp/LHA_category.py index dde91060fa..f0f2d4eb76 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_category.py +++ b/policyengine_uk/variables/gov/dwp/LHA_category.py @@ -27,17 +27,8 @@ def formula(benunit, period, parameters): person = benunit.members household = person.household is_shared = benunit.any(household("is_shared_accommodation", period.this_year)) - num_adults_in_hh = benunit.max(household.sum(person("is_adult", period))) - eldest_adult_age_in_hh = benunit.max(household.max(person("age", period))) - has_children = benunit.any(person("is_child", period)) - # Households with only one adult, if under age threshold, can only - # claim shared if without children: - # https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/28 - p = parameters(period).gov.dwp.LHA - can_only_claim_shared = ( - (num_adults_in_hh == 1) - & (eldest_adult_age_in_hh < p.shared_accommodation_age_threshold) - & ~has_children + can_only_claim_shared = benunit( + "is_lha_shared_accommodation_rate_specified_renter", period ) return select( [ diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap.py b/policyengine_uk/variables/gov/dwp/benefit_cap.py index 2f53751034..f67e218041 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap.py @@ -9,9 +9,7 @@ class benefit_cap(Variable): unit = GBP def formula(benunit, period, parameters): - is_single_adult = benunit("num_adults", period) == 1 - has_children = benunit("num_children", period) > 0 - single_claimant = is_single_adult & ~has_children + single_claimant = benunit("is_benefit_cap_single_claimant_rate", period) household_region = benunit.members.household("region", period) region = benunit.value_from_first_person(household_region) regions = household_region.possible_values @@ -32,4 +30,4 @@ def formula(benunit, period, parameters): ], ) exempt = benunit("is_benefit_cap_exempt", period) - return where(exempt, np.inf * np.ones_like(has_children), rate) + return where(exempt, np.inf, rate) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py new file mode 100644 index 0000000000..e86210da62 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class is_benefit_cap_single_claimant_rate(Variable): + value_type = bool + entity = BenUnit + label = "Benefit cap single claimant rate applies" + documentation = ( + "Whether the claimant has no partner and is not responsible for a " + "child or qualifying young person for Universal Credit. The model " + "uses one benefit cap for Universal Credit and Housing Benefit. " + "Housing Benefit instead uses the Child Benefit young-person " + "definition. Couple status is a proxy for joint-claim status and " + "does not capture Universal Credit couples who may claim as singles." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/80A", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/75CA", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + ) + + def formula(benunit, period, parameters): + return ~benunit("is_couple", period) & ~benunit( + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + period, + ) diff --git a/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py new file mode 100644 index 0000000000..cda112fc86 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class is_lha_shared_accommodation_rate_specified_renter(Variable): + value_type = bool + entity = BenUnit + label = "LHA renter restricted to the shared accommodation rate" + documentation = ( + "The modelled specified-renter conditions: no partner, below the shared " + "accommodation age threshold, not responsible for a UC child or " + "qualifying young person, and no non-dependant under the household " + "composition proxy. UC Schedule 4 paragraph 29 exceptions and couples " + "claiming as single people are not modelled. This category also serves " + "Housing Benefit, whose young-person definition instead follows Child " + "Benefit (HB regulation 19)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/27", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/28", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/29", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.LHA + return ( + ~benunit("is_couple", period) + & ( + benunit("eldest_claimant_or_partner_age", period) + < p.shared_accommodation_age_threshold + ) + & ~benunit( + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + period, + ) + & ~benunit("lha_renter_has_non_dependant", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py new file mode 100644 index 0000000000..70692b2e7a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class lha_renter_has_non_dependant(Variable): + value_type = bool + entity = BenUnit + label = "LHA renter has a non-dependant (household composition proxy)" + documentation = ( + "A conservative proxy: a claimant or partner of another benefit unit " + "in the household, or someone in this benefit unit who is neither a " + "claimant/partner nor a UC child or qualifying young person. The model " + "does not identify the statutory exclusions for joint renters, " + "commercial lodgers, landlords or foster children here. Rent liability " + "alone does not exclude another benefit unit's claimant." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + ) + + def formula(benunit, period, parameters): + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + child_or_qyp = person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) + within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) + other_benefit_unit_claimants = benunit.max( + person.household.sum(claimant_or_partner) + ) - benunit.sum(claimant_or_partner) + return within_benefit_unit | (other_benefit_unit_claimants > 0) From 31e70089c6616da5db424f811fcca84390b74cff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:53:48 -0400 Subject: [PATCH 10/24] Apply Universal Credit's claimant rules instead of age-18 counts The age basic conditions apply to claimants only, with the observable regulation 8 cases lowering the minimum age to 16 (WRA 2012 ss.3-4; UC Regs 2013 regs 3 and 8); the childcare work condition tests the claimant and partner (reg 32); the household capital proxy is shared by claimants and partners (WRA s.5; reg 18). Built with a gpt-6-astra subfleet lane. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-claimant-rules.fixed.md | 1 + .../universal_credit/eligibility/min_age.yaml | 9 + .../eligibility/reduced_min_age.yaml | 9 + .../uc_childcare_work_condition.yaml | 151 +++++++++++- .../claimant_relationships.yaml | 12 +- .../universal_credit/is_UC_eligible.yaml | 12 +- .../meets_uc_minimum_age_condition.yaml | 217 ++++++++++++++++++ .../test_uc_capital_allocation.py | 96 ++++++++ .../uc_assessable_capital.yaml | 165 +++++++++++++ .../uc_individual_child_element.py | 6 +- .../uc_childcare_work_condition.py | 15 +- .../household_uc_unreported_adults.py | 21 -- .../household_uc_unreported_claimants.py | 28 +++ .../dwp/universal_credit/is_uc_eligible.py | 21 +- .../meets_uc_minimum_age_condition.py | 48 ++++ .../universal_credit/uc_assessable_capital.py | 24 +- .../is_uc_work_allowance_eligible.py | 6 +- 17 files changed, 782 insertions(+), 59 deletions(-) create mode 100644 changelog.d/uc-claimant-rules.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/min_age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/reduced_min_age.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/meets_uc_minimum_age_condition.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_adults.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py diff --git a/changelog.d/uc-claimant-rules.fixed.md b/changelog.d/uc-claimant-rules.fixed.md new file mode 100644 index 0000000000..7156013368 --- /dev/null +++ b/changelog.d/uc-claimant-rules.fixed.md @@ -0,0 +1 @@ +- Apply Universal Credit age conditions, childcare work conditions, and household capital allocation to claimants and partners, with documented proxies for the observable minimum-age exceptions from age 16. Rename the capital allocation helper to `household_uc_unreported_claimants`. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/min_age.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/min_age.yaml new file mode 100644 index 0000000000..a97bf9116d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/min_age.yaml @@ -0,0 +1,9 @@ +description: Default minimum age for Universal Credit claimants. +values: + 2013-04-29: 18 +metadata: + unit: year + label: universal credit minimum age + reference: + - title: Welfare Reform Act 2012, section 4(1)(a) + href: https://www.legislation.gov.uk/ukpga/2012/5/section/4 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/reduced_min_age.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/reduced_min_age.yaml new file mode 100644 index 0000000000..5a4c2c77b5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/eligibility/reduced_min_age.yaml @@ -0,0 +1,9 @@ +description: Reduced minimum age for Universal Credit claimants meeting an age exception. +values: + 2013-04-29: 16 +metadata: + unit: year + label: universal credit reduced minimum age + reference: + - title: The Universal Credit Regulations 2013, regulation 8(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/8 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml index 5e88f76b19..55a589cd02 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition.yaml @@ -4,6 +4,7 @@ input: people: person: + age: 30 in_work: false output: uc_childcare_work_condition: false @@ -14,6 +15,7 @@ input: people: person: + age: 30 in_work: true output: uc_childcare_work_condition: true @@ -25,10 +27,10 @@ people: person1: in_work: false - is_adult: true + age: 40 person2: in_work: true - is_adult: false + age: 17 output: uc_childcare_work_condition: false @@ -39,10 +41,10 @@ people: person1: in_work: false - is_adult: true + age: 40 person2: in_work: true - is_adult: true + age: 39 benunits: benunit: members: [person1, person2] @@ -56,12 +58,149 @@ people: person1: in_work: true - is_adult: true + age: 40 person2: in_work: true - is_adult: true + age: 39 benunits: benunit: members: [person1, person2] output: uc_childcare_work_condition: true + +- name: Nonworking dependent aged 18 does not block a working claimant's childcare + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + in_work: true + young_person: + age: 18 + is_in_non_advanced_education: true + in_work: false + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, young_person, child] + output: + # Reg. 32 tests claimant/partner work, not the dependent young person's: + # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 + # Reg. 34: min(5,000 * 85%, 1,031.88 * 12) = 4,250, formerly zero. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/34 + is_uc_claimant: [true, false, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Nonworking dependent aged 19 does not block working joint claimants' childcare + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + in_work: true + partner: + age: 39 + in_work: true + young_person: + age: 19 + is_in_non_advanced_education: true + in_work: false + child: + age: 3 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, partner, young_person, child] + output: + # Reg. 32: both claimants work; the dependent young person is excluded. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 + # Reg. 34: min(5,000 * 85%, 1,031.88 * 12) = 4,250, formerly zero. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/34 + is_uc_claimant: [true, true, false, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Working claimant aged 16 satisfies the childcare work condition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 16 + is_benunit_head: true + is_parent: true + in_work: true + child: + age: 1 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, child] + output: + # Reg. 32: the single claimant works; age 18 is not a work condition. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 + # Reg. 34: min(5,000 * 85%, 1,031.88 * 12) = 4,250, formerly zero. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/34 + is_uc_claimant: [true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: Working claimant aged 17 satisfies the childcare work condition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 17 + is_benunit_head: true + is_parent: true + in_work: true + child: + age: 1 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, child] + output: + # Reg. 32: the single claimant works; age 18 is not a work condition. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 + # Reg. 34: min(5,000 * 85%, 1,031.88 * 12) = 4,250, formerly zero. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/34 + is_uc_claimant: [true, false] + uc_childcare_work_condition: true + uc_childcare_element: 4_250 + +- name: An explicitly identified nonworking partner aged 17 fails the work condition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 20 + is_parent: true + is_uc_claimant: true + in_work: true + partner: + age: 17 + is_uc_claimant: true + in_work: false + child: + age: 1 + childcare_expenses: 5_000 + benunits: + benunit: + members: [parent, partner, child] + output: + # Reg. 32(1)(b) applies to a partner regardless of age. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/32 + # No partner exception is modelled: failed work condition * 5,000 * 85% + # = 0, formerly 4,250 when the partner was excluded by the age-18 flag. + uc_childcare_work_condition: false + uc_childcare_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_relationships.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_relationships.yaml index c57eb5efa5..e9eee500eb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_relationships.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_relationships.yaml @@ -126,9 +126,11 @@ uc_work_allowance: 0 universal_credit: 5_970.60 -# The existing eligibility formula excludes all under-18 claimants. This case -# fixes claimant classification only; it does not implement the legal exceptions. -- name: Case 5, an under-18 parent is not a couple because adult count is zero. +# UC Regulations 2013 reg.8(1)(d) lowers the minimum age to 16 for a claimant +# responsible for a child: https://www.legislation.gov.uk/uksi/2013/376/regulation/8 +# This deliberate change admits the 17-year-old lone parent previously excluded. +# 2025 UC = (316.98 standard allowance + 292.81 child element) * 12 = 7,317.48. +- name: Case 5, a 17-year-old lone parent meets the reduced UC minimum age. period: 2025 absolute_error_margin: 0.01 input: @@ -151,7 +153,9 @@ output: uc_standard_allowance_claimant_type: SINGLE_YOUNG uc_standard_allowance: 3_803.76 - universal_credit: 0 + is_uc_eligible: true + uc_child_element: 3_513.72 + universal_credit: 7_317.48 - name: Case 6, retained nonqualifying child roles do not imply a partner. period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml index fa145e20c1..926ade283d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml @@ -1,4 +1,4 @@ -- name: Has no working-age adults +- name: Has no claimants meeting the age conditions period: 2020 absolute_error_margin: 0 input: @@ -13,7 +13,7 @@ output: is_uc_eligible: false -- name: Has one working-age adult +- name: Has one claimant meeting the age conditions period: 2020 absolute_error_margin: 0 input: @@ -28,7 +28,7 @@ output: is_uc_eligible: true -- name: Working-age adult remains eligible at the upper capital limit +- name: Claimant meeting the age conditions remains eligible at the upper capital limit period: 2025 absolute_error_margin: 0 input: @@ -45,7 +45,7 @@ output: is_uc_eligible: true -- name: Working-age adult is ineligible above the upper capital limit +- name: Claimant meeting the age conditions is ineligible above the upper capital limit period: 2025 absolute_error_margin: 0 input: @@ -105,7 +105,7 @@ uc_assessable_capital: [10_000, 10_000] is_uc_eligible: [true, true] -- name: Multi-benunit households split proxy capital equally across adults +- name: Multi-benunit households split proxy capital equally across claimants period: 2025 absolute_error_margin: 0 input: @@ -174,7 +174,7 @@ uc_assessable_capital: [12_000, 8_000] is_uc_eligible: [true, true] -- name: Multi-benunit proxy capital is weighted by adults in each benunit +- name: Multi-benunit proxy capital is weighted by claimants in each benunit period: 2025 absolute_error_margin: 0 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/meets_uc_minimum_age_condition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/meets_uc_minimum_age_condition.yaml new file mode 100644 index 0000000000..0a3e3288f9 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/meets_uc_minimum_age_condition.yaml @@ -0,0 +1,217 @@ +# WRA 2012 s.4(1)(a): standard minimum age = 18. +# UC Regulations 2013 reg.8(1): reduced minimum age = 16. +# https://www.legislation.gov.uk/ukpga/2012/5/section/4 +# https://www.legislation.gov.uk/uksi/2013/376/regulation/8 + +- name: Standard UC minimum age starts at 18 + period: 2025 + input: + age: [15, 16, 17, 18, 19] + output: + # No exception applies: age >= 18. + meets_uc_minimum_age_condition: [false, false, false, true, true] + +- name: LCWRA proxy permits 16 and 17 year old claimants + period: 2025 + input: + age: [15, 16, 17, 18] + uc_limited_capability_for_WRA: [true, true, true, true] + uc_reported_capital: [0, 0, 0, 0] + output: + # Reg.8(1)(a)-(b) proxy: true only once age >= 16. + meets_uc_minimum_age_condition: [false, true, true, true] + is_uc_eligible: [false, true, true, true] + +- name: Caring hours proxy starts at 35 hours for age 16 and over + period: 2025 + input: + age: [15, 16, 16, 17, 17] + care_hours: [35, 34, 35, 34, 35] + uc_reported_capital: [0, 0, 0, 0, 0] + output: + # Reg.8(1)(c) proxy: (age >= 16) AND (hours >= 35). + meets_uc_minimum_age_condition: [false, false, true, false, true] + is_uc_eligible: [false, false, true, false, true] + +- name: Receipt of a carer benefit also meets the caring proxy + period: 2025 + input: + age: 16 + care_hours: 0 + receives_carer_benefit: true + uc_reported_capital: 0 + output: + # Reg.8(1)(c) proxy: age >= 16 AND carer benefit receipt. + meets_uc_minimum_age_condition: true + is_uc_eligible: true + +- name: A 16 year old claimant responsible for a child meets the reduced age + period: 2025 + input: + people: + claimant: + age: 16 + is_benunit_head: true + child: + age: 1 + is_benunit_head: false + benunits: + family: + members: [claimant, child] + uc_reported_capital: 0 + output: + # Reg.8(1)(d): claimant age 16 >= 16 and nonclaimant child age 1 < 16. + is_uc_claimant: [true, false] + meets_uc_minimum_age_condition: [true, false] + is_uc_eligible: true + +- name: A 15 year old parent remains below the reduced minimum age + period: 2025 + input: + people: + claimant: + age: 15 + is_uc_claimant: true + child: + age: 1 + is_uc_claimant: false + benunits: + family: + members: [claimant, child] + uc_reported_capital: 0 + output: + # Even with responsibility, 15 < 16. + meets_uc_minimum_age_condition: [false, false] + is_uc_eligible: false + +- name: Responsibility for a child aged 15 still meets reg 8 paragraph d + period: 2025 + input: + people: + claimant: + age: 17 + is_uc_claimant: true + child: + age: 15 + is_uc_claimant: false + benunits: + family: + members: [claimant, child] + uc_reported_capital: 0 + output: + # Responsibility need not mean biological parenthood: 17 >= 16 and + # the nonclaimant aged 15 is still below the child age limit of 16. + meets_uc_minimum_age_condition: [true, false] + is_uc_eligible: true + +- name: Responsibility for a qualifying young person alone is not reg 8 paragraph d + period: 2025 + input: + people: + claimant: + age: 17 + is_uc_claimant: true + young_person: + age: 16 + is_uc_claimant: false + is_in_non_advanced_education: true + benunits: + family: + members: [claimant, young_person] + uc_reported_capital: 0 + output: + # Reg.8(1)(d) says child (age < 16), so age 16 does not qualify. + meets_uc_minimum_age_condition: [false, false] + is_uc_eligible: false + +- name: A claimant cannot count another claimant as a dependent child + period: 2025 + input: + people: + claimant: + age: 17 + is_uc_claimant: true + partner: + age: 15 + is_uc_claimant: true + benunits: + family: + members: [claimant, partner] + uc_reported_capital: 0 + output: + # There are zero nonclaimant children; neither claimant meets age 18. + meets_uc_minimum_age_condition: [false, false] + is_uc_eligible: false + +- name: An under 18 claimant cannot gain eligibility from an older dependent student + period: 2025 + input: + people: + claimant: + age: 17 + is_benunit_head: true + is_parent: true + young_person: + age: 18 + is_benunit_head: false + is_in_non_advanced_education: true + benunits: + family: + members: [claimant, young_person] + uc_reported_capital: 0 + output: + # The claimant is under 18 with no child under 16; the 18-year-old is + # not a claimant. Zero claimants satisfy the age conditions. + is_uc_claimant: [true, false] + meets_uc_minimum_age_condition: [false, true] + is_uc_eligible: false + +- name: A pension age claimant cannot gain eligibility from an 18 year old dependant + period: 2025 + input: + people: + claimant: + age: 70 + is_parent: true + is_benunit_head: true + young_person: + age: 18 + is_benunit_head: false + is_in_non_advanced_education: true + benunits: + family: + members: [claimant, young_person] + uc_reported_capital: 0 + output: + # WRA ss.3-4 apply to claimants: 70 >= SPA; age 18 is a nonclaimant. + # Previously the dependent student's age alone made this unit eligible. + is_uc_claimant: [true, false] + is_uc_eligible: false + +- name: Mixed age couple retains eligibility through its younger claimant + period: 2025 + input: + people: + older: + age: 70 + younger: + age: 60 + benunits: + couple: + members: [older, younger] + uc_reported_capital: 0 + output: + # Reg.3(2)(a): one claimant is age 60, between minimum age 18 and SPA. + is_uc_claimant: [true, true] + is_uc_eligible: true + +- name: Reduced minimum age does not bypass the capital limit + period: 2025 + input: + age: [16, 17] + uc_limited_capability_for_WRA: [true, true] + uc_reported_capital: [16_000, 16_001] + output: + # Both ages >= 16; only 16,000 <= the 16,000 capital limit. + meets_uc_minimum_age_condition: [true, true] + is_uc_eligible: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py new file mode 100644 index 0000000000..30c50614f6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py @@ -0,0 +1,96 @@ +"""The household capital proxy conserves residual capital within each household.""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings, strategies as st +from policyengine_core.simulations import Simulation + +from policyengine_uk.system import system + + +@st.composite +def household_capital_situations(draw): + household_specs = draw( + st.lists( + st.tuples( + st.integers(min_value=0, max_value=200_000), + st.lists( + st.tuples( + st.integers(min_value=1, max_value=2), + st.integers(min_value=0, max_value=2), + st.booleans(), + st.integers(min_value=0, max_value=50_000), + ), + min_size=1, + max_size=4, + ), + ), + min_size=1, + max_size=3, + ) + ) + people, benunits, households, expected = {}, {}, {}, [] + for household_index, (capital, unit_specs) in enumerate(household_specs): + household_members = [] + unreported_indices = [] + reported_values = {} + for unit_index, ( + claimant_count, + dependant_count, + has_reported, + reported_capital, + ) in enumerate(unit_specs): + # Every generated household has at least one unreported unit. + has_reported = has_reported and unit_index > 0 + unit_id = f"household_{household_index}_unit_{unit_index}" + members = [] + for person_index in range(claimant_count + dependant_count): + person_id = f"{unit_id}_person_{person_index}" + is_claimant = person_index < claimant_count + people[person_id] = { + "age": {2025: 17 if is_claimant else 19}, + "is_uc_claimant": {2025: is_claimant}, + "is_benunit_head": {2025: person_index == 0}, + } + members.append(person_id) + if has_reported: + reported_values[len(benunits)] = reported_capital + else: + unreported_indices.append(len(benunits)) + benunits[unit_id] = { + "members": members, + "uc_reported_capital": {2025: reported_capital if has_reported else -1}, + } + household_members.extend(members) + households[f"household_{household_index}"] = { + "members": household_members, + "savings": {2025: capital}, + } + expected.append( + ( + unreported_indices, + reported_values, + max(0, capital - sum(reported_values.values())), + ) + ) + return {"people": people, "benunits": benunits, "households": households}, expected + + +# Test conservation independently of machine-dependent generation timings. +@settings( + max_examples=30, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given(household_capital_situations()) +def test_unreported_capital_shares_sum_to_each_households_residual(case): + situation, expected = case + # Reuse baseline rules; each example still gets fresh people and inputs. + simulation = Simulation(tax_benefit_system=system, situation=situation) + assessed_capital = simulation.calculate("uc_assessable_capital", 2025) + + assert np.isfinite(assessed_capital).all() + for unreported_indices, reported_values, residual in expected: + assert assessed_capital[unreported_indices].sum() == pytest.approx(residual) + for index, reported_capital in reported_values.items(): + assert assessed_capital[index] == pytest.approx(reported_capital) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml new file mode 100644 index 0000000000..99e1fbfc52 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml @@ -0,0 +1,165 @@ +# WRA 2012 s.5 assesses the claimant's or joint claimants' capital; +# UC Regulations 2013 reg.18(2) includes a single claimant's partner's capital. +# https://www.legislation.gov.uk/ukpga/2012/5/section/5 +# https://www.legislation.gov.uk/uksi/2013/376/regulation/18 +# Allocating unreported household capital by claimant/partner count is a +# PolicyEngine convention, not a statutory allocation rule. + +- name: A qualifying young person does not increase their parents' capital share. + period: 2025 + input: + people: + parent: + age: 40 + is_benunit_head: true + is_parent: true + partner: + age: 39 + is_benunit_head: false + is_parent: true + qualifying_young_person: + age: 18 + is_benunit_head: false + is_in_non_advanced_education: true + other_claimant: + age: 25 + is_benunit_head: true + benunits: + parents: + members: [parent, partner, qualifying_young_person] + other: + members: [other_claimant] + households: + household: + members: [parent, partner, qualifying_young_person, other_claimant] + savings: 36_000 + output: + is_uc_claimant: [true, true, false, true] + household_uc_unreported_claimants: 3 + # Previously 36,000 * 3/4 = 27,000 and 36,000 * 1/4 = 9,000. + # Claimant weights give 36,000 * 2/3 = 24,000 and 36,000 / 3 = 12,000. + uc_assessable_capital: [24_000, 12_000] + +- name: An under-18 claimant receives the full capital proxy in their household. + period: 2025 + input: + people: + claimant: + age: 17 + is_benunit_head: true + is_parent: true + child: + age: 1 + is_benunit_head: false + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + savings: 20_000 + output: + is_uc_claimant: [true, false] + household_uc_unreported_claimants: 1 + # Previously zero; the claimant now receives 20,000 * 1/1 = 20,000. + uc_assessable_capital: 20_000 + +- name: Under-18 and older claimants receive equal unreported capital shares. + period: 2025 + input: + people: + young_claimant: + age: 16 + is_benunit_head: true + older_claimant: + age: 30 + is_benunit_head: true + benunits: + young: + members: [young_claimant] + older: + members: [older_claimant] + households: + household: + members: [young_claimant, older_claimant] + savings: 36_000 + output: + household_uc_unreported_claimants: 2 + # Previously [0, 36,000]; each now receives 36,000 * 1/2 = 18,000. + uc_assessable_capital: [18_000, 18_000] + +- name: Reported capital leaves a residual split between unreported claimants. + period: 2025 + input: + people: + reported_parent: + age: 40 + is_benunit_head: true + is_parent: true + qualifying_young_person: + age: 19 + is_benunit_head: false + is_in_non_advanced_education: true + young_claimant: + age: 17 + is_benunit_head: true + older_claimant: + age: 30 + is_benunit_head: true + partner: + age: 31 + is_benunit_head: false + benunits: + reported: + members: [reported_parent, qualifying_young_person] + uc_reported_capital: 8_000 + young: + members: [young_claimant] + couple: + members: [older_claimant, partner] + households: + household: + members: [reported_parent, qualifying_young_person, young_claimant, older_claimant, partner] + savings: 32_000 + output: + household_uc_reported_capital: 8_000 + household_uc_unreported_claimants: 3 + # 32,000 - 8,000 = 24,000 residual. Shares are 24,000 / 3 = 8,000 + # and 24,000 * 2/3 = 16,000. Previously [8,000, 0, 24,000]. + uc_assessable_capital: [8_000, 8_000, 16_000] + +- name: An explicit zero capital report excludes that unit from the proxy. + period: 2025 + input: + people: + reported_claimant: + age: 40 + is_benunit_head: true + unreported_claimant: + age: 30 + is_benunit_head: true + benunits: + reported: + members: [reported_claimant] + uc_reported_capital: 0 + unreported: + members: [unreported_claimant] + households: + household: + members: [reported_claimant, unreported_claimant] + savings: 12_000 + output: + household_uc_unreported_claimants: 1 + # 12,000 - 0 = 12,000 residual, allocated entirely to the unreported unit. + uc_assessable_capital: [0, 12_000] + +- name: A situation with no claimant roles has a zero proxy without division by zero. + period: 2025 + input: + age: 17 + is_uc_claimant: false + savings: 12_000 + output: + household_uc_unreported_claimants: 0 + # No claimant weights means no allocation: guarded zero instead of 0/0. + uc_assessable_capital: 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py index a253eeabc1..bca4fe80d0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py @@ -11,10 +11,10 @@ class uc_individual_child_element(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child - child_index = person("uc_child_index", period) + uc_child_index = person("uc_child_index", period) born_before_limit = person("uc_is_child_born_before_child_limit", period) child_limit_applying = where(~born_before_limit, p.limit.child_count, inf) - is_eligible = (child_index != -1) & (child_index <= child_limit_applying) + is_eligible = (uc_child_index != -1) & (uc_child_index <= child_limit_applying) # Reform proposal age_exemption = ( @@ -29,7 +29,7 @@ def formula(person, period, parameters): return ( select( [ - (child_index == 1) & born_before_limit & is_eligible, + (uc_child_index == 1) & born_before_limit & is_eligible, is_eligible, ], [ diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py index b1a0765b15..661786c65f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_childcare_work_condition.py @@ -5,14 +5,17 @@ class uc_childcare_work_condition(Variable): value_type = bool entity = BenUnit label = "Meets Universal Credit childcare work condition" + documentation = ( + "Tests work for the claimant and partner, excluding dependants. " + "The in_work proxy means positive hours or earnings; offers of work, " + "the partner exceptions in regulation 32(1)(b), and the treated-as-working " + "rules in regulation 32(2) are not modelled." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32/2020-04-06" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/32" def formula(benunit, period, parameters): person = benunit.members - adult = person("is_adult", period) + claimant = person("is_uc_claimant", period) in_work = person("in_work", period) - adults_in_work = adult & in_work - # Benefit unit must not have any adults not in work. - all_adults_in_work = benunit.all(in_work | ~adult) - return benunit.any(adults_in_work) & all_adults_in_work + return benunit.any(claimant & in_work) & benunit.all(in_work | ~claimant) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_adults.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_adults.py deleted file mode 100644 index 5b080f916e..0000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_adults.py +++ /dev/null @@ -1,21 +0,0 @@ -from policyengine_uk.model_api import * - - -class household_uc_unreported_adults(Variable): - value_type = int - entity = Household - label = "Number of adults in benunits without reported UC capital" - documentation = ( - "Adults counted in the residual household-capital proxy for Universal " - "Credit, including adults in non-claimant benunits when capital " - "ownership cannot be identified." - ) - definition_period = YEAR - unit = "adult" - - def formula(household, period, parameters): - person = household.members - reported_capital = person.benunit("uc_reported_capital", period) - has_reported_capital = reported_capital >= 0 - is_adult = person("is_adult", period) - return household.sum(is_adult * ~has_reported_capital) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py new file mode 100644 index 0000000000..66786f1877 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_claimants.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class household_uc_unreported_claimants(Variable): + value_type = int + entity = Household + label = "Number of claimants and partners in benunits without reported UC capital" + documentation = ( + "Claimants and partners counted in PolicyEngine's allocation of residual " + "household capital for Universal Credit. This includes claimant and " + "partner roles in benefit units that do not receive Universal Credit. " + "Allocating unidentified household capital by these counts is a " + "modelling convention; legislation defines whose capital is assessed, " + "not how household-level data should be allocated." + ) + definition_period = YEAR + unit = "person" + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/18", + ) + + def formula(household, period, parameters): + person = household.members + reported_capital = person.benunit("uc_reported_capital", period) + has_reported_capital = reported_capital >= 0 + is_uc_claimant = person("is_uc_claimant", period) + return household.sum(is_uc_claimant * ~has_reported_capital) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index 2dbd88a4f9..f8c205416b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -5,11 +5,26 @@ class is_uc_eligible(Variable): value_type = bool entity = BenUnit label = "Eligible for the Universal Credit" - documentation = "Whether this family is eligible for Universal Credit" + documentation = ( + "Whether a claimant or partner meets the modelled minimum-age and " + "pension-age conditions and the benefit unit meets the capital limit. " + "One qualifying claimant suffices, preserving mixed-age couples under " + "regulation 3(2)(a). Dependants cannot satisfy the claimant age test." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/3", + "https://www.legislation.gov.uk/ukpga/2012/5/section/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/3", + ) def formula(benunit, period, parameters): capital = benunit("uc_assessable_capital", period) limit = parameters(period).gov.dwp.universal_credit.means_test.capital.limit - has_working_age_adult = benunit.any(benunit.members("is_WA_adult", period)) - return has_working_age_adult & (capital <= limit) + claimant = benunit.members("is_uc_claimant", period) + meets_minimum_age = benunit.members("meets_uc_minimum_age_condition", period) + pension_age = benunit.members("is_SP_age", period) + has_qualifying_claimant = benunit.any( + claimant & meets_minimum_age & ~pension_age + ) + return has_qualifying_claimant & (capital <= limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py new file mode 100644 index 0000000000..cd1ad10c7f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * + + +class meets_uc_minimum_age_condition(Variable): + value_type = bool + entity = Person + label = "Meets the Universal Credit minimum age condition" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/8", + ) + documentation = """ + The standard minimum age is 18; regulation 8 permits claims from age 16 + in specified circumstances. Observable proxies cover limited capability + for work, caring, and responsibility for a child. + + uc_limited_capability_for_WRA is the existing LCWRA-named proxy (it defaults + to is_disabled_for_benefits), not an observation of LCW or a pending + assessment supported by medical evidence under regulation 8(1)(a)-(b). + is_carer_for_benefits means receipt of Carer's Allowance or Scottish Carer + Support Payment, or at least the Carer's Allowance qualifying hours of + care (currently 35 weekly). Its hours limb does not verify the recipient's + qualifying disability benefit, and it does not exclude paid care. + + For regulation 8(1)(d), responsibility is proxied by being a claimant in a + benefit unit with a nonclaimant child under the UC child definition. A + qualifying young person alone does not satisfy this limb. Benefit-unit + membership does not observe individual legal responsibility. + + Regulation 8(1)(e)-(g) (a partner's responsibility, pregnancy/recent birth, + and absence of parental support) are not observed. The regulation 8(2) + care-leaver exclusion, which applies to (c), (f), and (g), is also not + observed. These provisions are not separately modelled. + """ + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.universal_credit.eligibility + age = person("age", period) + claimant = person("is_uc_claimant", period) + child = person("is_child_for_universal_credit", period) + responsible_for_child = claimant & person.benunit.any(child & ~claimant) + reduced_age_exception = ( + person("uc_limited_capability_for_WRA", period) + | person("is_carer_for_benefits", period) + | responsible_for_child + ) + return (age >= p.min_age) | ((age >= p.reduced_min_age) & reduced_age_exception) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index eef9c8f729..7852bb78ec 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -7,11 +7,21 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Universal Credit capital counted from the configured capital sources, " - "with benunit-reported overrides when available." + "with benunit-reported overrides when available. PolicyEngine allocates " + "the remaining household capital between unreported benefit units in " + "proportion to their claimant and partner counts, including units that " + "do not receive Universal Credit. This allocation is a modelling " + "convention when capital ownership is unobserved, not a statutory rule. " + "The claimant and partner owner set follows section 5 of the Welfare " + "Reform Act 2012 and regulation 18 of the Universal Credit Regulations." ) definition_period = YEAR unit = GBP quantity_type = STOCK + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/18", + ) def formula(benunit, period, parameters): household = benunit.household @@ -19,20 +29,20 @@ def formula(benunit, period, parameters): household_capital = sum( household(source, period) for source in p.capital.sources ) - benunit_adults = add(benunit, period, ["is_adult"]) + benunit_claimants = add(benunit, period, ["is_uc_claimant"]) household_reported_capital = household("household_uc_reported_capital", period) - household_unreported_adults = household( - "household_uc_unreported_adults", period + household_unreported_claimants = household( + "household_uc_unreported_claimants", period ) - adult_divisor = max_(1, household_unreported_adults) + claimant_divisor = max_(1, household_unreported_claimants) reported_capital = benunit("uc_reported_capital", period) use_reported_capital = reported_capital >= 0 residual_household_capital = max_( 0, household_capital - household_reported_capital ) household_capital_proxy = where( - household_unreported_adults > 0, - residual_household_capital * benunit_adults / adult_divisor, + household_unreported_claimants > 0, + residual_household_capital * benunit_claimants / claimant_divisor, 0, ) assessed_capital = where( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py index 28307abe94..46dbed7c6d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py @@ -11,8 +11,8 @@ class is_uc_work_allowance_eligible(Variable): def formula(benunit, period, parameters): person = benunit.members has_LCWRA = benunit.any(person("uc_limited_capability_for_WRA", period)) - has_children = benunit.any( - person("is_child_or_qualifying_young_person_for_universal_credit", period) - & ~person("is_uc_claimant", period) + has_children = benunit( + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + period, ) return has_LCWRA | has_children From 029b2629785dd99c8f93fd0dff63071a10f090f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 14:59:21 -0400 Subject: [PATCH 11/24] Pay Guarantee Credit only within Pension Credit; add changelog fragments guarantee_credit was computed for every benefit unit below the minimum guarantee, including working-age families, and the targeted childcare entitlement treated it as a qualifying benefit. Guarantee credit is part of State Pension Credit (SPCA 2002 ss.1-2), so it is now zero unless the unit is Pension Credit eligible. Co-Authored-By: Claude Opus 5.5 --- .../generic-child-adult-flags.added.md | 1 + .../generic-child-adult-flags.changed.md | 1 + .../generic-child-adult-flags.fixed.md | 3 +++ .../claimant_partner_rates.yaml | 27 +++++++++++++++++++ .../guarantee_credit/guarantee_credit.py | 13 ++++++++- 5 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 changelog.d/generic-child-adult-flags.added.md create mode 100644 changelog.d/generic-child-adult-flags.changed.md create mode 100644 changelog.d/generic-child-adult-flags.fixed.md diff --git a/changelog.d/generic-child-adult-flags.added.md b/changelog.d/generic-child-adult-flags.added.md new file mode 100644 index 0000000000..7d0f6ccaf7 --- /dev/null +++ b/changelog.d/generic-child-adult-flags.added.md @@ -0,0 +1 @@ +- Added HBAI population variables (`is_hbai_dependent_child`, `is_hbai_adult`, `is_hbai_working_age_adult`, `is_hbai_pensioner`, `hbai_person_type`, and the equivalence-scale child bands) following the HBAI FYE 2025 glossary, `is_claimant_or_partner` for the single adult or couple a benefit unit is formed around, and programme-named child, young person and responsibility variables for Universal Credit, tax credits, the legacy means-tested benefits, the WTC childcare element and student support. diff --git a/changelog.d/generic-child-adult-flags.changed.md b/changelog.d/generic-child-adult-flags.changed.md new file mode 100644 index 0000000000..121660fc9a --- /dev/null +++ b/changelog.d/generic-child-adult-flags.changed.md @@ -0,0 +1 @@ +- Deprecated the generic age-18 variables (`is_child`, `is_adult`, `num_children`, `num_adults`, `family_type`, `child_index`, the eldest and youngest child and adult ages, `benunit_count_children`, `benunit_count_adults`, the disabled child and adult counts, `is_WA_adult`, `is_young_child`, `is_older_child`). They keep their formulas for downstream users, but no model rule reads them any more: each programme uses its own legal definition of a child or young person and of the claimant and partner. diff --git a/changelog.d/generic-child-adult-flags.fixed.md b/changelog.d/generic-child-adult-flags.fixed.md new file mode 100644 index 0000000000..13174e7dde --- /dev/null +++ b/changelog.d/generic-child-adult-flags.fixed.md @@ -0,0 +1,3 @@ +- Fixed benefit-unit structure: a lone parent living with an 18- or 19-year-old still at school is no longer treated as a (married) couple, a couple with such a dependant is no longer treated as single or a lone parent, and a 16- or 17-year-old heading their own benefit unit is a single claimant rather than a couple with themselves as the child. Lone parent, single claimant and couple are now mutually exclusive. +- Fixed Marriage Allowance and the CPS marriage reforms to transfer allowance and split income only between spouses or civil partners, never from a child in the benefit unit; the Tax-Free Childcare work condition to test the applicant and partner only; the WTC lone parent, couple, disability and childcare elements and eligibility routes to use the tax credit claimant and child definitions; and the DfE schemes (Care to Learn, Childcare Grant, Parents' Learning Allowance, extended childcare income test) to use each scheme's own definition of a child or of the parent and partner. +- The HBAI equivalence scale now weights HBAI adults and dependent children, which changes factors only in households with no one aged 18 or over. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml index b42bbe07ee..257efed7e3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_rates.yaml @@ -61,3 +61,30 @@ # (18,000 - 329.75 * 52) * 0.6 = 511.80, below maximum 1,045.20. savings_credit: 511.80 pension_credit_entitlement: 5_616.44 + +- name: Guarantee Credit is zero for a working-age family + # Guarantee credit is part of State Pension Credit (SPCA 2002 ss.1-2), so a + # working-age family on a low income has none. Previously it was computed for + # everyone below the minimum guarantee, which passported working-age families + # into the targeted (two-year-old) childcare entitlement. + period: 2026 + input: + people: + parent_1: + age: 30 + is_parent: true + parent_2: + age: 30 + is_parent: true + toddler: + age: 2 + benunits: + benunit: + members: [parent_1, parent_2, toddler] + households: + household: + members: [parent_1, parent_2, toddler] + country: ENGLAND + output: + is_pension_credit_eligible: false + guarantee_credit: 0 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/guarantee_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/guarantee_credit.py index 2eb5599e97..6f2c714ac5 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/guarantee_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/guarantee_credit.py @@ -7,7 +7,18 @@ class guarantee_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/2" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/2", + ) + documentation = ( + "The guarantee credit element of State Pension Credit. It is part of " + "State Pension Credit, so it is zero unless the benefit unit meets " + "the Pension Credit entitlement conditions (including State Pension " + "age for the claimant and any partner); other schemes that passport " + "on Guarantee Credit read this variable." + ) + defined_for = "is_pension_credit_eligible" def formula(benunit, period, parameters): income = benunit("pension_credit_income", period) From d4b67459dd3382b15ad4f37e0db502e0abaca9cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 21:36:46 -0400 Subject: [PATCH 12/24] Add property and differential tests for the child, adult and claimant definitions Exhaustive and Hypothesis checks that each programme's child and young-person predicates match the encoded statutory limbs for every age and education status, that HBAI types and benefit-unit classifications partition, that the deprecated shims keep their age-18 formulas and agree with the explicit age_under_18 predicates where the rule is age-only, and that adding a child never changes who the claimant and partner are. The last property found that a parent flag on an under-16 made an older student a dependant; an identified parent must now be aged 16 or over. Tests built with a gpt-6-astra subfleet lane. Co-Authored-By: Claude Opus 5.5 --- ..._child_and_adult_definitions_properties.py | 661 ++++++++++++++++++ .../hbai/is_hbai_dependent_child.py | 6 +- 2 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 policyengine_uk/tests/test_child_and_adult_definitions_properties.py diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py new file mode 100644 index 0000000000..83592e18a3 --- /dev/null +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -0,0 +1,661 @@ +"""Programme definitions, benefit-unit structure and frozen age-18 shims. + +Invariants: + +1. For every age 0--25, education enum and combination of head, parent, + approved training, own-benefit receipt and looked-after status, the four + programmes' child predicates mean under 16. Their young-person predicates + agree with the statutory age/education/entry conditions the model encodes. + Legacy children are Child Benefit children/QYPs other than claimants and + partners; the HBAI fallback follows its documented structural assumptions. +2. HBAI types partition everyone. Valid families (one or two adults aged 20+ + and dependants) have one or two claimants/partners; couple/single and + couple/lone-parent/single-person partition benefit units. Heads aged 16+ + are claimants or partners, including when explicitly younger than others. +3. All 23 deprecated shims retain their original age-18 formulas, including + empty-group sentinels and historical eldest-child tie behaviour. Explicit + age_under_18 has the same membership as the shims for school-attendance + inputs and household adult age/capital-gains ranks (modelling conventions, + not statutory adult definitions). +4. Adding an under-16 dependant leaves existing claimant/partner membership + unchanged, even when the added child carries a parent marker (an + identified parent must be aged 16 or over; the minimised counterexample + that found this is kept as a regression test). + Before/after families share one Simulation to avoid repeated + model construction, but occupy distinct households and benefit units. + +Scope: these are tests of the ENCODED statutory limbs, not full entitlement. +Education status proxies full-time non-advanced study: hours, employment +contracts, approved providers and interruptions are unobserved. CB/CTC leaver +extensions are omitted, as is UC/PC's unconditional age-16-to-next-September +route. UC's terminal date is an input; PC's September terminal date is absent. +Own-benefit receipt collapses different programme-specific benefit lists. +UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's +fallback assumes dependence at 16--17 regardless of study, requires an +identified parent at 18--19 and infers partnership from head/parent status. +WTC childcare uses annual ages <16/<17 instead of the September/week cutoff; +disability inputs omit some statutory awards/history. Student support uses +benefit-unit membership as a proxy for financial dependence. These existing +limitations are preserved here, not treated as new model defects. + +Statutory texts and HBAI glossary were fetched on 2026-09-28; URLs accompany +the independent scalar references below. No network or survey data is used. +""" + +from itertools import product +from unittest.mock import patch + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +# Keep later mutations of the public module-level system out of these tests. +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +PROGRAMMES = ( + "child_benefit", + "universal_credit", + "child_tax_credit", + "pension_credit", +) +FLAGS = ( + "is_benunit_head", + "is_parent", + "is_in_approved_training", + "receives_benefits_in_own_right", + "is_looked_after_by_local_authority", +) +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_benunit_head": False, + "is_parent": False, + "is_in_approved_training": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "is_apprentice": False, + "age_started_or_accepted_current_education_or_training": 17, + "is_before_universal_credit_qualifying_young_person_terminal_date": False, + "is_disabled_for_benefits": False, + "is_enhanced_disabled_for_benefits": False, + "is_severely_disabled_for_benefits": False, + "is_blind": False, + "is_SP_age": False, + "capital_gains_before_response": 0, + **inputs, + } + + +def simulate(families, year=YEAR, household_ids=None): + """One model construction for many independent benefit units.""" + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, family in enumerate(families): + members = [] + for j, inputs in enumerate(family): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + members.append(name) + situation["benunits"][f"b{i}"] = {"members": members} + household = f"h{household_ids[i] if household_ids is not None else i}" + situation["households"].setdefault(household, {"members": []})[ + "members" + ].extend(members) + # Clone the real system, including independent parameters and variables. + # Reimporting every variable for each example creates thousands of new + # modules which Hypothesis then repeatedly scans for source constants. + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + # Scalar situation parsing searches the person ID list for every input. + # Set whole columns once to keep the exhaustive Cartesian grid tractable. + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + # Constructor reforms calculate some family flags before these inputs + # exist. Clear those results, retaining only the supplied columns. This + # helper tests definitions, not reform amounts or baseline comparisons. + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def non_advanced_education(p): + # Encoded proxy for >12 hours' non-advanced education; the enum does not + # observe hours. https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + return ( + p["current_education"] + not in { + "NOT_IN_EDUCATION", + "TERTIARY", + } + and not p["is_apprentice"] + ) + + +def statutory_child(age): + # SSCBA s142(1), WRA s40, TCA s8(3), SPC Regulations Sch IIA para 2: + # https://www.legislation.gov.uk/ukpga/1992/4/section/142 + # https://www.legislation.gov.uk/ukpga/2012/5/section/40 + # https://www.legislation.gov.uk/ukpga/2002/21/section/8 + # https://www.legislation.gov.uk/uksi/2002/1792/schedule/IIA + return age < 16 + + +def qualifying_young_person(p, programme): + # Education/training routes only (see omissions in module docstring): + # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 + # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 + # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if not (non_advanced_education(p) or p["is_in_approved_training"]): + return False + if ( + p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + ): + return False + if programme == "universal_credit" and p["age"] >= 19: + return p["is_before_universal_credit_qualifying_young_person_terminal_date"] + return True + + +def child_or_young_person(p, programme): + # Encoded responsibility filters, without their statutory exceptions: + # https://www.legislation.gov.uk/uksi/2013/376/regulation/4 + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/3 + # https://www.legislation.gov.uk/uksi/2002/1792/schedule/IIA + eligible = statutory_child(p["age"]) or qualifying_young_person(p, programme) + if programme != "child_benefit" and p["is_looked_after_by_local_authority"]: + return False + return eligible + + +def hbai_dependent_child(p, family): + # Calculator fallback documented in is_hbai_dependent_child, with the + # deliberately limited household/education observations described above. + # https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025#child + if p["age"] < 16: + return True + if p["is_benunit_head"] or p["is_parent"]: + return False + if p["age"] < 18: + return True + return ( + p["age"] < 20 + and any(member["is_parent"] for member in family) + and (non_advanced_education(p) or p["is_in_approved_training"]) + ) + + +def wtc_childcare_child(p, family): + # Annual proxy for reg14(3)-(4), not the precise September/week cutoff: + # https://www.legislation.gov.uk/uksi/2002/2005/regulation/14 + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/3 + limit = 17 if p["is_disabled_for_benefits"] or p["is_blind"] else 16 + return ( + hbai_dependent_child(p, family) + and not p["is_looked_after_by_local_authority"] + and p["age"] < limit + ) + + +def assert_values(sim, variable, expected, year=YEAR): + actual = sim.calculate(variable, year) + if hasattr(actual, "decode_to_str"): + actual = actual.decode_to_str() + np.testing.assert_array_equal(actual, expected, err_msg=variable) + + +def assert_definitions(families, year=YEAR): + sim = simulate(families, year) + people = [p for family in families for p in family] + children = [statutory_child(p["age"]) for p in people] + # This is the membership input to private-school imputation. A calculator + # simulation's attends_private_school result is always zero and would + # therefore be a vacuous test of the imputation's age predicate. + under_18 = [p["age"] < 18 for p in people] + assert_values(sim, "age_under_18", under_18, year) + assert_values(sim, "is_child", under_18, year) + assert_values(sim, "is_adult", [not child for child in under_18], year) + assert_values( + sim, + "is_in_non_advanced_education", + [non_advanced_education(p) for p in people], + year, + ) + for programme in PROGRAMMES: + assert_values(sim, f"is_child_for_{programme}", children, year) + assert_values( + sim, + f"is_qualifying_young_person_for_{programme}", + [qualifying_young_person(p, programme) for p in people], + year, + ) + assert_values( + sim, + f"is_child_or_qualifying_young_person_for_{programme}", + [child_or_young_person(p, programme) for p in people], + year, + ) + hbai = [hbai_dependent_child(p, family) for family in families for p in family] + assert_values(sim, "is_hbai_dependent_child", hbai, year) + assert_values(sim, "is_hbai_adult", [not child for child in hbai], year) + assert_values(sim, "is_claimant_or_partner", [not child for child in hbai], year) + # SSCBA s137 and IS reg14 / HB reg19, projected to the encoded family: + # https://www.legislation.gov.uk/ukpga/1992/4/section/137 + # https://www.legislation.gov.uk/uksi/1987/1967/regulation/14 + # https://www.legislation.gov.uk/uksi/2006/213/regulation/19 + assert_values( + sim, + "is_child_or_young_person_for_legacy_benefits", + [ + child and child_or_young_person(p, "child_benefit") + for p, child in zip(people, hbai) + ], + year, + ) + assert_values( + sim, + "is_child_for_working_tax_credit_childcare_element", + [wtc_childcare_child(p, family) for family in families for p in family], + year, + ) + # Financial dependence is unobserved: test the documented membership proxy. + # https://www.legislation.gov.uk/uksi/2011/1986/regulation/42 + assert_values(sim, "is_dependent_child_for_student_support", hbai, year) + assert_hbai_partition(sim, people, hbai, year) + assert_structure(sim, families, year) + + +def assert_hbai_partition(sim, people, hbai, year=YEAR): + working = [not child and not p["is_SP_age"] for p, child in zip(people, hbai)] + pensioners = [not child and p["is_SP_age"] for p, child in zip(people, hbai)] + assert_values(sim, "is_hbai_working_age_adult", working, year) + assert_values(sim, "is_hbai_pensioner", pensioners, year) + assert_values( + sim, + "hbai_person_type", + [ + "CHILD" if child else "PENSIONER" if retired else "WORKING_AGE_ADULT" + for child, retired in zip(hbai, pensioners) + ], + year, + ) + np.testing.assert_array_equal( + np.array(hbai, dtype=int) + np.array(working) + np.array(pensioners), 1 + ) + for variable, younger in ( + ("is_hbai_child_under_14", True), + ("is_hbai_child_aged_14_or_over", False), + ): + assert_values( + sim, + variable, + [child and ((p["age"] < 14) == younger) for p, child in zip(people, hbai)], + year, + ) + + +def assert_structure(sim, families, year=YEAR): + claimants = sim.calculate("is_claimant_or_partner", year) + couple = sim.calculate("is_couple", year) + single = sim.calculate("is_single", year) + lone = sim.calculate("is_lone_parent", year) + single_person = sim.calculate("is_single_person", year) + np.testing.assert_array_equal(couple ^ single, True) + np.testing.assert_array_equal( + couple.astype(int) + lone.astype(int) + single_person.astype(int), 1 + ) + offset = 0 + for i, family in enumerate(families): + expected = [not hbai_dependent_child(p, family) for p in family] + actual = claimants[offset : offset + len(family)] + np.testing.assert_array_equal(actual, expected) + assert couple[i] == (sum(expected) >= 2) + responsible = any( + not claimant and child_or_young_person(p, "child_benefit") + for p, claimant in zip(family, expected) + ) + assert lone[i] == (sum(expected) < 2 and responsible) + for p, claimant in zip(family, actual): + if p["is_benunit_head"] and p["age"] >= 16: + assert claimant + offset += len(family) + + +def test_every_age_education_and_structural_flag_combination(): + # 26 ages * 8 education values * 2**5 flags = 6,656 independent families. + # The matrix includes deliberately unusual inputs: an under-16 head or a + # student parent must not change the pure statutory under-16 definition. + families = [] + for age, education, flags in product( + range(26), EDUCATIONS, product((False, True), repeat=5) + ): + subject = person(age, current_education=education, **dict(zip(FLAGS, flags))) + parent = person( + 40, is_parent=True, is_benunit_head=not subject["is_benunit_head"] + ) + families.append([parent, subject]) + assert_definitions(families) + + +def test_entry_terminal_and_wtc_disability_boundaries(): + # Guarantee the age-19 entry/terminal boundaries and the distinct disabled + # and blind routes at WTC ages 15/16/17, independently of generated draws. + families = [] + for age, education, entry_age, flags in product( + range(15, 21), + ("POST_SECONDARY", "TERTIARY", "NOT_IN_EDUCATION"), + (18.5, 19, 1000), + product((False, True), repeat=5), + ): + apprentice, terminal, training, disabled, blind = flags + subject = person( + age, + current_education=education, + age_started_or_accepted_current_education_or_training=entry_age, + is_apprentice=apprentice, + is_before_universal_credit_qualifying_young_person_terminal_date=terminal, + is_in_approved_training=training, + is_disabled_for_benefits=disabled, + is_blind=blind, + ) + families.append([person(40, is_parent=True, is_benunit_head=True), subject]) + assert_definitions(families) + + +@PROPERTY_SETTINGS +@given( + st.fixed_dictionaries({flag: st.booleans() for flag in FLAGS}), + st.booleans(), + st.booleans(), + st.booleans(), + st.booleans(), + st.booleans(), + st.sampled_from([0, 16, 17, 18, 18.5, 19, 20, 25, 1000]), + st.integers(20, 90), + st.sampled_from([2024, 2026]), +) +def test_programme_definitions_with_generated_entry_and_household_context( + flags, + apprentice, + terminal, + disabled, + blind, + identified_parent, + entry_age, + parent_age, + year, +): + families = [] + for age, education in product(range(26), EDUCATIONS): + subject = person( + age, + current_education=education, + **flags, + is_apprentice=apprentice, + is_before_universal_credit_qualifying_young_person_terminal_date=terminal, + is_disabled_for_benefits=disabled, + is_blind=blind, + age_started_or_accepted_current_education_or_training=entry_age, + ) + parent = person( + parent_age, + is_parent=identified_parent, + is_benunit_head=not flags["is_benunit_head"], + is_SP_age=parent_age >= 66, + ) + families.append([parent, subject]) + assert_definitions(families, year) + + +@st.composite +def valid_families(draw): + families = [] + for _ in range(draw(st.integers(1, 8))): + adults = draw(st.lists(st.integers(20, 90), min_size=1, max_size=2)) + ages = draw(st.lists(st.integers(0, 19), max_size=5)) + family = [ + person( + age, is_parent=bool(ages), is_benunit_head=i == 0, is_SP_age=age >= 66 + ) + for i, age in enumerate(adults) + ] + for age in ages: + # The generator creates dependants, not additional claimants: + # 18--19-year-olds require education/training and a known parent. + education = draw(st.sampled_from(EDUCATIONS)) + training = draw(st.booleans()) + if age >= 18 and education in {"NOT_IN_EDUCATION", "TERTIARY"}: + training = True + family.append( + person( + age, current_education=education, is_in_approved_training=training + ) + ) + families.append(family) + return families + + +@PROPERTY_SETTINGS +@given(valid_families(), st.integers(0, 15)) +def test_valid_family_partition_and_adding_a_child_preserves_claimants( + families, added_age +): + # Keep every existing input fixed; even an unidentified parent stays so. + augmented = [family + [person(added_age)] for family in families] + combined = families + augmented + sim = simulate(combined) + assert_structure(sim, combined) + people = [p for family in combined for p in family] + hbai = [hbai_dependent_child(p, family) for family in combined for p in family] + assert_hbai_partition(sim, people, hbai) + claimants = sim.calculate("is_claimant_or_partner", YEAR) + old_offset = 0 + new_offset = sum(map(len, families)) + for family in families: + old = claimants[old_offset : old_offset + len(family)] + new = claimants[new_offset : new_offset + len(family)] + assert sum(old) == sum(p["age"] >= 20 for p in family) + assert sum(old) in (1, 2) + np.testing.assert_array_equal(new, old) + assert not claimants[new_offset + len(family)] + old_offset += len(family) + new_offset += len(family) + 1 + + +def test_calculated_heads_are_claimants_at_16_or_over(): + families = [[person(age)] for age in range(26)] + families += [[person(16), person(16)], [person(20), person(25)]] + # The default head is eldest, with ties resolved by member order. + for family in families: + for p in family: + del p["is_benunit_head"] + sim = simulate(families) + heads = sim.calculate("is_benunit_head", YEAR) + claimants = sim.calculate("is_claimant_or_partner", YEAR) + offset = 0 + for family in families: + expected_head = max(range(len(family)), key=lambda i: family[i]["age"]) + np.testing.assert_array_equal( + heads[offset : offset + len(family)], + [i == expected_head for i in range(len(family))], + ) + if family[expected_head]["age"] >= 16: + assert claimants[offset + expected_head] + offset += len(family) + + +@st.composite +def shim_populations(draw): + members = st.fixed_dictionaries( + { + "age": st.integers(0, 90), + "current_education": st.sampled_from(EDUCATIONS), + "is_disabled_for_benefits": st.booleans(), + "is_enhanced_disabled_for_benefits": st.booleans(), + "is_severely_disabled_for_benefits": st.booleans(), + "is_SP_age": st.booleans(), + "person_id": st.integers(0, 20), + "capital_gains_before_response": st.integers(0, 100_000), + } + ) + raw = draw( + st.lists(st.lists(members, min_size=1, max_size=7), min_size=1, max_size=8) + ) + return [[person(**p) for p in family] for family in raw] + + +def frozen_rank(people, value, eligible, excluded): + """Stable descending rank, using Python sorting independently of core.""" + ordered = sorted( + (i for i, p in enumerate(people) if eligible(p)), + key=lambda i: -people[i][value], + ) + result = [excluded] * len(people) + for rank, i in enumerate(ordered, start=1): + result[i] = rank + return result + + +@PROPERTY_SETTINGS +@given(shim_populations()) +def test_all_deprecated_shims_and_explicit_age_ranks_keep_frozen_semantics(families): + # Always exercise both empty subgroups, all 0--25 age boundaries, stable + # ties, and the frozen distinction between exactly two and three adults. + families += [ + [person(age, person_id=age + 1) for age in range(26)], + [person(18, person_id=1)], + [person(18, person_id=1), person(18, person_id=2)], + [person(18, person_id=i) for i in range(3)], + [person(0, person_id=3), person(1, person_id=1), person(1, person_id=2)], + ] + # Two benefit units share each household: adult ranks must not restart at + # a benefit-unit boundary, whereas child ranks and counts must do so. + household_ids = [i // 2 for i in range(len(families))] + sim = simulate(families, household_ids=household_ids) + people = [p for family in families for p in family] + expected = { + "is_child": [p["age"] < 18 for p in people], + "is_adult": [p["age"] >= 18 for p in people], + "age_under_18": [p["age"] < 18 for p in people], + "is_WA_adult": [p["age"] >= 18 and not p["is_SP_age"] for p in people], + "is_young_child": [p["age"] < 14 for p in people], + "is_older_child": [14 <= p["age"] < 18 for p in people], + } + for family in families: + ages = [p["age"] for p in family] + children = [age for age in ages if age < 18] + adults = [age for age in ages if age >= 18] + group_values = { + "num_children": len(children), + "benunit_count_children": len(children), + "num_adults": len(adults), + "benunit_count_adults": len(adults), + "eldest_child_age": max(children, default=-np.inf), + "youngest_child_age": min(children, default=np.inf), + "eldest_adult_age": max(adults, default=-np.inf), + "youngest_adult_age": min(adults, default=np.inf), + "family_type": ( + "COUPLE_WITH_CHILDREN" if children else "COUPLE_NO_CHILDREN" + ) + if len(adults) == 2 + else ("LONE_PARENT" if children else "SINGLE"), + } + for suffix in ("disabled", "enhanced_disabled", "severely_disabled"): + for group, child in (("children", True), ("adults", False)): + group_values[f"num_{suffix}_{group}"] = sum( + ((p["age"] < 18) == child) and p[f"is_{suffix}_for_benefits"] + for p in family + ) + for variable, value in group_values.items(): + expected.setdefault(variable, []).append(value) + ranks = frozen_rank(family, "age", lambda p: p["age"] < 18, -1) + expected.setdefault("child_index", []).extend(ranks) + # Frozen oddity: every member of a childless family is "eldest child". + expected.setdefault("is_eldest_child", []).extend( + not children or rank == 1 for rank in ranks + ) + # Frozen oddity: a younger child's larger ID can leave tied eldest + # children with no selected eldest. Do not silently fix this shim. + eldest = max(children, default=-np.inf) + tied = children.count(eldest) > 1 + max_child_id = max(p["person_id"] if p["age"] < 18 else 0 for p in family) + expected.setdefault("is_benunit_eldest_child", []).extend( + p["age"] == eldest and (not tied or p["person_id"] == max_child_id) + for p in family + ) + households = {} + for household, family in zip(household_ids, families): + households.setdefault(household, []).extend(family) + for variable, value in ( + ("adult_index", "age"), + ("adult_index_cg", "capital_gains_before_response"), + ): + expected[variable] = [ + rank + for household in households.values() + for rank in frozen_rank(household, value, lambda p: p["age"] >= 18, 0) + ] + for variable, values in expected.items(): + assert_values(sim, variable, values) + + +@PROPERTY_SETTINGS +@given(st.integers(0, 15), st.sampled_from(EDUCATIONS), st.booleans()) +def test_adding_a_child_without_identifying_parents_preserves_existing_members( + added_age, education, training +): + families = [ + [ + person(24, is_benunit_head=True), + person(age, current_education=education, is_in_approved_training=training), + ] + for age in range(16, 20) + ] + combined = families + [family + [person(added_age)] for family in families] + sim = simulate(combined) + actual = sim.calculate("is_claimant_or_partner", YEAR) + for i in range(len(families)): + np.testing.assert_array_equal( + actual[2 * i : 2 * i + 2], actual[8 + 3 * i : 10 + 3 * i] + ) + + +def test_parent_marked_under_16_entrant_preserves_existing_claimants(): + # Executed and minimised: 20 is the smallest ordinary-adult age in our + # family generator, 18 the first affected student age, and 0 the smallest + # entrant age. Before: claimants [True, True]. After: [True, False, False]. + # The entrant is itself an HBAI dependent child despite its parent marker. + family = [ + person(20, is_benunit_head=True), + person(18, current_education="POST_SECONDARY"), + ] + sim = simulate([family, family + [person(0, is_parent=True)]]) + actual = sim.calculate("is_claimant_or_partner", YEAR) + np.testing.assert_array_equal(actual[:2], actual[2:4]) diff --git a/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py b/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py index 0c1d587c88..8405d73814 100644 --- a/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py +++ b/policyengine_uk/variables/household/demographic/hbai/is_hbai_dependent_child.py @@ -24,7 +24,7 @@ class is_hbai_dependent_child(Variable): - an 18- or 19-year-old who is neither the head nor an identified parent is a dependent child only if they are in non-advanced education or approved training and the benefit unit contains an identified parent - (`is_parent`). Without an identified parent the model cannot tell a + (`is_parent`, aged 16 or over). Without an identified parent the model cannot tell a dependant from a partner, so it keeps treating them as an adult. This is a statistical definition. Benefit and tax rules use each @@ -45,8 +45,10 @@ def formula(person, period, parameters): in_education_or_training = person( "is_in_non_advanced_education", period ) | person("is_in_approved_training", period) - lives_with_identified_parent = person.benunit.any(is_parent) under_child_age = age < p.age_limit + # An identified parent is a benefit-unit member aged 16 or over + # flagged as a parent; a parent flag on a child is ignored. + lives_with_identified_parent = person.benunit.any(is_parent & ~under_child_age) under_18_dependant = lives_as_dependant & person("age_under_18", period) young_person = ( lives_as_dependant From 2e5fc689497c232f56e2b9644a6bbe7e61cfe51f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:58:59 -0400 Subject: [PATCH 13/24] Test Housing Benefit's pension-age route on the claimant and partner housing_benefit_eligible (from #1901) read is_adult; like is_uc_eligible and is_pension_credit_eligible it now tests the claimant and any partner (SI 2014/1230 reg 6A(4)), so a pensioner with an 18 or 19 year old dependant can claim Housing Benefit and not Universal Credit. Co-Authored-By: Claude Opus 5.5 --- ..._housing_benefit_pension_age_properties.py | 16 ++++++++------ .../housing_benefit_eligible.py | 22 +++++++++---------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py b/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py index 37672b3c3d..be59a68feb 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py @@ -13,9 +13,9 @@ above the capital limit; and no family gets both Housing Benefit and Universal Credit. 2. Calculator mode (no reported benefits): a family is eligible exactly when - every adult is over State Pension age, it rents, and its capital is within - the limit; eligible families are paid their full entitlement (pensioners - are exempt from the benefit cap). + the claimant and any partner are over State Pension age, it rents, and its + capital is within the limit; eligible families are paid their full + entitlement (pensioners are exempt from the benefit cap). 3. Dataset mode (claims_all_entitled_benefits False, as in the FRS): Housing Benefit is paid only to reported claimants, and for families with a working-age adult eligibility equals the continuing-award rule (reported, @@ -25,9 +25,10 @@ 5. Metamorphic: a wholly pension-age family's Housing Benefit is non-increasing in private pension income. -A pensioner with an 18 or 19 year old dependant counts as having a working-age -adult for Housing Benefit, Pension Credit and Universal Credit alike, so the -mutual exclusion holds for that shape too. +A pensioner with an 18 or 19 year old dependant is a pension-age claimant for +Housing Benefit, Pension Credit and Universal Credit alike (the dependant is +not a claimant or partner), so that family can claim Housing Benefit and not +Universal Credit, and the mutual exclusion holds for that shape too. """ import numpy as np @@ -151,7 +152,8 @@ def calculate(units, **kwargs): def wholly_pension_age(unit): - return all(role != "dependant" and age >= 67 for role, age in unit["members"]) + # The claimant and any partner, not dependants (SI 2014/1230 reg 6A(4)). + return all(age >= 67 for role, age in unit["members"] if role != "dependant") def check_structural(values): diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 9cab415763..6c65db61b8 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -6,8 +6,9 @@ class housing_benefit_eligible(Variable): entity = BenUnit label = "eligible for the Housing Benefit" documentation = ( - "Whether this family can receive Housing Benefit. A family in which " - "every adult has reached the qualifying age for State Pension Credit " + "Whether this family can receive Housing Benefit. A family whose " + "claimant and any partner have reached the qualifying age for State " + "Pension Credit " "can make a new claim; Universal Credit is not available to it. Any " "other family keeps Housing Benefit only while it continues an " "existing award (reported Housing Benefit) and does not claim " @@ -28,15 +29,14 @@ def formula(benunit, period, parameters): sp_age = person("is_SP_age", period) # New claims are barred except where the claimant, and any partner, # has reached the qualifying age for State Pension Credit - # (SI 2014/1230 reg 6A(4); NI: SR 2016/226 reg 4A(4)). Every adult in - # the benefit unit stands in for the claimant and partner, as in - # is_uc_eligible and is_pension_credit_eligible, so a pensioner with - # an 18 or 19 year old dependant is routed to Universal Credit. - # Because is_uc_eligible needs a working-age adult, no family on this - # route receives Universal Credit; change the three together. - adult = person("is_adult", period) - adult_count = benunit.sum(adult) - pension_age = (adult_count > 0) & (benunit.sum(adult & sp_age) == adult_count) + # (SI 2014/1230 reg 6A(4); NI: SR 2016/226 reg 4A(4)). This is the + # same claimant-and-partner set is_uc_eligible and + # is_pension_credit_eligible use, so a pensioner with an 18 or 19 year + # old dependant can claim Housing Benefit and not Universal Credit; + # change the three together. + claimant_or_partner = person("is_claimant_or_partner", period) + count = benunit.sum(claimant_or_partner) + pension_age = (count > 0) & (benunit.sum(claimant_or_partner & sp_age) == count) # Working-age and mixed-age families (since 15 May 2019) claim # Universal Credit instead. They keep an existing award until they # claim it (reg 8(2A)), so this route also rules out receiving From 098525197ca33a6cb3b0357ac9841f5265d8d60e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:45:44 -0400 Subject: [PATCH 14/24] Limit a benefit unit to its head and one partner is_claimant_or_partner treated every HBAI adult as a claimant or partner, so a couple entered with an 18-year-old (no relationship or education inputs) had three claimants: both parents drew Marriage Allowance from the child, and a working lone parent failed the Tax-Free Childcare work condition because the child counted as a partner who must work. The claimant is now the benefit-unit head and the partner at most one other HBAI adult: a member flagged as a parent, otherwise the eldest other adult who is not presumed the head's child (under 20 and at least 16 years younger, a PolicyEngine presumption held as parameters). Datasets and users can supply the flag directly. Tests: both review probes as default-input regressions, presumption cases, and a Hypothesis property over arbitrary benefit units (at most two claimants, at least one exactly when there is an HBAI adult, claimants are HBAI adults). The Housing Benefit property fixture now sets is_claimant_or_partner from each generated role. Co-Authored-By: Claude Opus 5.5 --- .../generic-child-adult-flags.fixed.md | 1 + .../presumed_child/age_limit.yaml | 6 + .../presumed_child/minimum_age_gap.yaml | 6 + .../universal_credit/is_uc_claimant.yaml | 9 +- .../allowances/marriage_allowance.yaml | 20 ++++ .../tax_free_childcare_work_condition.yaml | 23 ++++ .../demographic/benefit_unit_structure.yaml | 110 ++++++++++++++++++ ..._child_and_adult_definitions_properties.py | 99 +++++++++++++--- ..._housing_benefit_pension_age_properties.py | 12 +- .../demographic/is_claimant_or_partner.py | 42 +++++-- 10 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml create mode 100644 policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml diff --git a/changelog.d/generic-child-adult-flags.fixed.md b/changelog.d/generic-child-adult-flags.fixed.md index 13174e7dde..3218a3361d 100644 --- a/changelog.d/generic-child-adult-flags.fixed.md +++ b/changelog.d/generic-child-adult-flags.fixed.md @@ -1,3 +1,4 @@ - Fixed benefit-unit structure: a lone parent living with an 18- or 19-year-old still at school is no longer treated as a (married) couple, a couple with such a dependant is no longer treated as single or a lone parent, and a 16- or 17-year-old heading their own benefit unit is a single claimant rather than a couple with themselves as the child. Lone parent, single claimant and couple are now mutually exclusive. +- A benefit unit now has at most two claimants or partners: the head and one partner (a member flagged as a parent if there is one, otherwise the eldest other adult). Without relationship inputs, a member under 20 and at least 16 years younger than the head is presumed to be the head's child rather than their partner, so a lone parent with an 18-year-old at university stays single and a couple with one stays a couple of two. Datasets and users can supply `is_claimant_or_partner` directly. - Fixed Marriage Allowance and the CPS marriage reforms to transfer allowance and split income only between spouses or civil partners, never from a child in the benefit unit; the Tax-Free Childcare work condition to test the applicant and partner only; the WTC lone parent, couple, disability and childcare elements and eligibility routes to use the tax credit claimant and child definitions; and the DfE schemes (Care to Learn, Childcare Grant, Parents' Learning Allowance, extended childcare income test) to use each scheme's own definition of a child or of the parent and partner. - The HBAI equivalence scale now weights HBAI adults and dependent children, which changes factors only in households with no one aged 18 or over. diff --git a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml new file mode 100644 index 0000000000..83ffb9fedd --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/age_limit.yaml @@ -0,0 +1,6 @@ +description: Age below which a benefit-unit member much younger than the head is presumed to be the head's child rather than their partner, when relationships are not supplied. This is a PolicyEngine modelling presumption, not a statutory rule. +values: + 0001-01-01: 20 +metadata: + unit: year + label: presumed child age limit diff --git a/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml new file mode 100644 index 0000000000..19f9dbe80f --- /dev/null +++ b/policyengine_uk/parameters/household/demographic/benefit_unit/presumed_child/minimum_age_gap.yaml @@ -0,0 +1,6 @@ +description: Minimum age gap below the benefit-unit head at which a younger member is presumed to be the head's child rather than their partner, when relationships are not supplied. This is a PolicyEngine modelling presumption, not a statutory rule. +values: + 0001-01-01: 16 +metadata: + unit: year + label: presumed child minimum age gap diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_claimant.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_claimant.yaml index 314fce3fe1..b3baa75c28 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_claimant.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_uc_claimant.yaml @@ -77,7 +77,7 @@ output: is_uc_claimant: true -- name: Case 6, an older member without qualifying education remains an adult fallback. +- name: Case 6, an adult child without qualifying education is neither a partner nor a UC child. period: 2025 input: people: @@ -90,6 +90,9 @@ benunit: members: [person1, person2] output: - # The default education input does not establish an older dependent QYP. + # The default (higher) education input does not establish a UC qualifying + # young person. An 18-year-old 22 years younger than the head is presumed + # the parent's child, not their partner, so the parent claims alone + # (previously the two were treated as joint claimants). is_child_or_qualifying_young_person_for_universal_credit: [false, false] - is_uc_claimant: [true, true] + is_uc_claimant: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index ab5901837f..673614ccf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -114,3 +114,23 @@ output: is_married: false marriage_allowance: [0, 0] + +- name: Parents do not receive Marriage Allowance from an 18-year-old's allowance + # Default inputs (no relationship or education flags). The couple are the + # spouses; their 18-year-old is neither. Each parent earns 40,000, so both + # use their full personal allowance and neither has one to transfer. + period: 2025 + input: + people: + parent_1: + age: 42 + employment_income: 40_000 + parent_2: + age: 40 + employment_income: 40_000 + student: + age: 18 + benunit: + members: [parent_1, parent_2, student] + output: + marriage_allowance: [0, 0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml index d17c4a4b8d..b0f8195c60 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/tax_free_childcare/tax_free_childcare_work_condition.yaml @@ -317,3 +317,26 @@ members: [parent_1, parent_2, student] output: tax_free_childcare_work_condition: [false, false, false] + +- name: A working lone parent with an 18-year-old and a toddler meets the work condition + # Default inputs: the 18-year-old is the parent's child, not a partner who + # would also have to work. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 80_000 + in_work: true + incapacity_benefit: 0 + student: + age: 18 + incapacity_benefit: 0 + toddler: + age: 3 + incapacity_benefit: 0 + benunits: + benunit: + members: [parent, student, toddler] + output: + tax_free_childcare_work_condition: [true, false, false] diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml index 7c59bc0cc8..ececb79ead 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -237,3 +237,113 @@ output: is_hbai_child_under_14: [false, true, false, false] is_hbai_child_aged_14_or_over: [false, false, true, true] + +# A benefit unit has one claimant or a couple, never more. Without relationship +# inputs, a member under 20 who is at least 16 years younger than the head is +# presumed the head's child, not their partner (a PolicyEngine presumption); +# any adults beyond a couple are neither claimant nor partner. + +- name: A couple with an 18-year-old in higher education stays a couple of two + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 42 + parent_2: + age: 40 + student: + age: 18 + benunits: + benunit: + members: [parent_1, parent_2, student] + households: + household: + members: [parent_1, parent_2, student] + output: + is_hbai_adult: [true, true, true] + is_claimant_or_partner: [true, true, false] + is_couple: true + marital_status: [MARRIED, MARRIED, SINGLE] + +- name: A lone parent with an 18-year-old in higher education is single + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + student: + age: 18 + toddler: + age: 3 + benunits: + benunit: + members: [parent, student, toddler] + households: + household: + members: [parent, student, toddler] + output: + is_claimant_or_partner: [true, false, false] + is_couple: false + +- name: A partner close in age is still a partner + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + partner: + age: 22 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + # 22 is not under 20, so no presumption applies. + is_claimant_or_partner: [true, true] + is_couple: true + +- name: Three unrelated adults form one couple plus a non-dependant + period: 2025 + absolute_error_margin: 0 + input: + people: + eldest: + age: 50 + second: + age: 48 + third: + age: 45 + benunits: + benunit: + members: [eldest, second, third] + households: + household: + members: [eldest, second, third] + output: + is_claimant_or_partner: [true, true, false] + +- name: A supplied claimant-or-partner flag overrides the presumption + # Supplying the flag replaces the formula for everyone, so set it for both. + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 45 + is_claimant_or_partner: true + partner: + age: 19 + is_claimant_or_partner: true + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + is_couple: true diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 83592e18a3..ad125febbf 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -6,10 +6,13 @@ approved training, own-benefit receipt and looked-after status, the four programmes' child predicates mean under 16. Their young-person predicates agree with the statutory age/education/entry conditions the model encodes. - Legacy children are Child Benefit children/QYPs other than claimants and - partners; the HBAI fallback follows its documented structural assumptions. -2. HBAI types partition everyone. Valid families (one or two adults aged 20+ - and dependants) have one or two claimants/partners; couple/single and + Legacy children are Child Benefit children/QYPs other than claimants, + partners and children placed by a local authority; the HBAI fallback and + the claimant-or-partner presumption follow their documented assumptions. +2. HBAI types partition everyone. Any benefit unit with one head has at most + two claimants/partners, all of them HBAI adults, and at least one if it + has an HBAI adult; valid families (one or two adults aged 20+ and + dependants) have exactly their adults. Couple/single and couple/lone-parent/single-person partition benefit units. Heads aged 16+ are claimants or partners, including when explicitly younger than others. 3. All 23 deprecated shims retain their original age-18 formulas, including @@ -46,7 +49,6 @@ from unittest.mock import patch import numpy as np -import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -212,6 +214,41 @@ def hbai_dependent_child(p, family): ) +def claimants_or_partners(family): + # is_claimant_or_partner's documented rule: an HBAI-adult head, plus one + # partner: the eldest other HBAI adult flagged as a parent, else the + # eldest other HBAI adult not presumed the head's child (under 20 and at + # least 16 years younger than the head). + adult = [not hbai_dependent_child(p, family) for p in family] + head_ages = [p["age"] for p in family if p["is_benunit_head"]] + head_age = max(head_ages) if head_ages else -np.inf + others = [i for i, p in enumerate(family) if adult[i] and not p["is_benunit_head"]] + parents = [i for i in others if family[i]["is_parent"]] + not_presumed_children = [ + i + for i in others + if not (family[i]["age"] < 20 and head_age - family[i]["age"] >= 16) + ] + pool = parents or not_presumed_children + partner = max(pool, key=lambda i: (family[i]["age"], -i)) if pool else None + return [ + (a and p["is_benunit_head"]) or i == partner + for i, (p, a) in enumerate(zip(family, adult)) + ] + + +def legacy_child_or_young_person(p, claimant): + # SSCBA s137 and IS reg14 / HB reg19, minus the claimant and partner and + # anyone placed by a local authority (IS reg16(4), HB reg21(3)): + # https://www.legislation.gov.uk/uksi/1987/1967/regulation/16 + # https://www.legislation.gov.uk/uksi/2006/213/regulation/21 + return ( + not claimant + and not p["is_looked_after_by_local_authority"] + and child_or_young_person(p, "child_benefit") + ) + + def wtc_childcare_child(p, family): # Annual proxy for reg14(3)-(4), not the precise September/week cutoff: # https://www.legislation.gov.uk/uksi/2002/2005/regulation/14 @@ -265,7 +302,8 @@ def assert_definitions(families, year=YEAR): hbai = [hbai_dependent_child(p, family) for family in families for p in family] assert_values(sim, "is_hbai_dependent_child", hbai, year) assert_values(sim, "is_hbai_adult", [not child for child in hbai], year) - assert_values(sim, "is_claimant_or_partner", [not child for child in hbai], year) + claimants = [c for family in families for c in claimants_or_partners(family)] + assert_values(sim, "is_claimant_or_partner", claimants, year) # SSCBA s137 and IS reg14 / HB reg19, projected to the encoded family: # https://www.legislation.gov.uk/ukpga/1992/4/section/137 # https://www.legislation.gov.uk/uksi/1987/1967/regulation/14 @@ -273,10 +311,7 @@ def assert_definitions(families, year=YEAR): assert_values( sim, "is_child_or_young_person_for_legacy_benefits", - [ - child and child_or_young_person(p, "child_benefit") - for p, child in zip(people, hbai) - ], + [legacy_child_or_young_person(p, c) for p, c in zip(people, claimants)], year, ) assert_values( @@ -287,7 +322,12 @@ def assert_definitions(families, year=YEAR): ) # Financial dependence is unobserved: test the documented membership proxy. # https://www.legislation.gov.uk/uksi/2011/1986/regulation/42 - assert_values(sim, "is_dependent_child_for_student_support", hbai, year) + assert_values( + sim, + "is_dependent_child_for_student_support", + [not claimant for claimant in claimants], + year, + ) assert_hbai_partition(sim, people, hbai, year) assert_structure(sim, families, year) @@ -333,12 +373,16 @@ def assert_structure(sim, families, year=YEAR): ) offset = 0 for i, family in enumerate(families): - expected = [not hbai_dependent_child(p, family) for p in family] + expected = claimants_or_partners(family) actual = claimants[offset : offset + len(family)] np.testing.assert_array_equal(actual, expected) + adults = [not hbai_dependent_child(p, family) for p in family] + assert sum(actual) <= 2 + assert (sum(actual) >= 1) == any(adults) + assert all(adult for adult, claimant in zip(adults, actual) if claimant) assert couple[i] == (sum(expected) >= 2) responsible = any( - not claimant and child_or_young_person(p, "child_benefit") + legacy_child_or_young_person(p, claimant) for p, claimant in zip(family, expected) ) assert lone[i] == (sum(expected) < 2 and responsible) @@ -511,6 +555,35 @@ def test_calculated_heads_are_claimants_at_16_or_over(): offset += len(family) +@st.composite +def arbitrary_benefit_units(draw): + # Any ages, education, parent flags and head position: not only the + # shapes the FRS produces. + families = [] + for _ in range(draw(st.integers(1, 8))): + size = draw(st.integers(1, 6)) + head = draw(st.integers(0, size - 1)) + families.append( + [ + person( + draw(st.integers(0, 90)), + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + is_parent=draw(st.booleans()), + is_benunit_head=i == head, + ) + for i in range(size) + ] + ) + return families + + +@PROPERTY_SETTINGS +@given(arbitrary_benefit_units()) +def test_any_benefit_unit_has_at_most_two_claimants_or_partners(families): + assert_structure(simulate(families), families) + + @st.composite def shim_populations(draw): members = st.fixed_dictionaries( diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py b/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py index be59a68feb..147b4e3fe9 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_properties.py @@ -29,6 +29,11 @@ Housing Benefit, Pension Credit and Universal Credit alike (the dependant is not a claimant or partner), so that family can claim Housing Benefit and not Universal Credit, and the mutual exclusion holds for that shape too. + +Every member's is_claimant_or_partner is set from its generated role, as the +FRS supplies it. Without that input an 18- or 19-year-old partner of a +pensioner would be presumed the pensioner's child; that presumption is tested +in test_child_and_adult_definitions_properties.py. """ import numpy as np @@ -91,7 +96,12 @@ def situation(units, claims_all=None, flip_would_claim_uc=False, pension_bump=0. names = [] for j, (role, age) in enumerate(unit["members"]): name = f"p{i}_{j}" - person = {"age": {YEAR: age}} + # Setting the input for anyone makes it an input for everyone, so + # set it for every member of every family. + person = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: role != "dependant"}, + } if role != "dependant" and age >= 67: person["state_pension_reported"] = {YEAR: unit["state_pension"]} person["private_pension_income"] = { diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py index 7f680bc04d..7999456f6f 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -5,16 +5,24 @@ class is_claimant_or_partner(Variable): """The single adult or couple a benefit unit is formed around. In benefit law this is the claimant and their partner, as opposed to the - children and young persons they are responsible for (SSCBA 1992 - s.137(1) "family"; WRA 2012 ss.39-40; SPCA 2002 s.17; TCA 2002 s.3). - The same two people are the spouses or civil partners for income tax - purposes when the benefit unit is a married couple. + children and young persons they are responsible for (SSCBA 1992 s.137(1) + "family"; WRA 2012 ss.39-40; SPCA 2002 s.17; TCA 2002 s.3). The same two + people are the spouses or civil partners for income tax purposes when the + benefit unit is a married couple. A benefit unit has one claimant, or a + couple: never more than two. - Benefit units follow the Family Resources Survey definition: a single - adult or a couple plus any dependent children. So the claimant and - partner are the benefit unit's adults under that definition, which is - the HBAI one (`is_hbai_adult`). Each programme applies its own age and - other conditions to them. + Datasets or users can supply this directly. Otherwise it is inferred from + benefit-unit structure, which follows the Family Resources Survey: a single + adult or a couple plus dependent children. + + - Only HBAI adults (`is_hbai_adult`) can be a claimant or partner. + - The benefit-unit head is the claimant. + - The partner is one other member: the eldest member flagged as a parent + (`is_parent`) if there is one, otherwise the eldest other adult who is + not presumed to be the head's child. A member under 20 and at least 16 + years younger than the head is presumed to be their child (a PolicyEngine + presumption for households entered without relationships). Any further + adults are neither claimant nor partner. """ value_type = bool @@ -29,4 +37,18 @@ class is_claimant_or_partner(Variable): ) def formula(person, period, parameters): - return person("is_hbai_adult", period) + p = parameters(period).household.demographic.benefit_unit.presumed_child + age = person("age", period) + adult = person("is_hbai_adult", period) + is_head = person("is_benunit_head", period) + head_age = person.benunit.max(where(is_head, age, -np.inf)) + presumed_child_of_head = (age < p.age_limit) & ( + head_age - age >= p.minimum_age_gap + ) + identified_partner = adult & ~is_head & person("is_parent", period) + other_adult = adult & ~is_head & ~presumed_child_of_head + pool = where( + person.benunit.any(identified_partner), identified_partner, other_adult + ) + partner = pool & (person.get_rank(person.benunit, -age, condition=pool) == 0) + return (is_head & adult) | partner From 72858279933b9bb9988e9e2ea1d32ea0fc7b5077 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:45:44 -0400 Subject: [PATCH 15/24] Apply both schemes' child tests and the disability exception to the cap and LHA category One benefit cap and one LHA category serve Universal Credit and Housing Benefit. Using only UC's qualifying young person moved HB families with a Child Benefit young person (HB reg 19) onto the single cap, and 16-year-olds outside education (UC reg 5(1)(a)) likewise. The family rates now apply if either scheme's test is met. Renters under 35 receiving attendance allowance, DLA care at the middle or highest rate, or PIP daily living are excepted from the shared accommodation rate (UC Sch 4 para 29(5)). Co-Authored-By: Claude Opus 5.5 --- ...-benefit-cap-claimant-definitions.fixed.md | 3 +- ...ared_accommodation_exception_benefits.yaml | 14 +++++ .../family/LHA_claimant_composition.yaml | 39 ++++++++++++++ .../policy/baseline/gov/dwp/benefit_cap.yaml | 52 +++++++++++++++++++ .../is_benefit_cap_single_claimant_rate.py | 12 ++--- ...red_accommodation_rate_specified_renter.py | 24 ++++++--- ..._young_person_for_uc_or_housing_benefit.py | 43 +++++++++++++++ .../gov/dwp/lha_renter_has_non_dependant.py | 9 +++- 8 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml create mode 100644 policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py diff --git a/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md index a02ad3ff96..a11ecd5885 100644 --- a/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md +++ b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md @@ -1 +1,2 @@ -- Replaced age-18 child/adult flags in LHA shared accommodation and benefit-cap rates with claimant/partner status and Universal Credit child responsibility, retaining the conservative LHA non-dependant proxy and documenting the shared Housing Benefit/Universal Credit modelling limits. +- Replaced age-18 child/adult flags in the LHA shared accommodation rate and the benefit-cap rates with claimant/partner status and responsibility for a child or young person. Because one LHA category and one benefit cap serve both Universal Credit and Housing Benefit, the family rates apply if either scheme's test is met (UC regs 4-5, including the reg 5(1)(a) route for 16-year-olds; HB reg 19), via the new `is_responsible_for_child_or_young_person_for_uc_or_housing_benefit`. +- Renters under 35 receiving attendance allowance, the DLA care component at the middle or highest rate, or the PIP daily living component are now excepted from the shared accommodation rate (UC Regulations 2013 Sch 4 para 29(5); HB Regulations 2006 reg 2(1)). The other paragraph 29 exceptions and the conservative LHA non-dependant proxy remain as documented limits. diff --git a/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml b/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml new file mode 100644 index 0000000000..0f8aca9183 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml @@ -0,0 +1,14 @@ +description: Disability benefits whose recipients under 35 are excepted from the shared accommodation rate. +values: + 2013-04-29: + - attendance_allowance + - dla_sc_middle_plus + - pip_dl +metadata: + unit: list + label: shared accommodation rate disability exceptions + reference: + - title: Universal Credit Regulations 2013 Sch 4 para 29(5) + href: https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/29 + - title: Housing Benefit Regulations 2006 reg 2(1) ("young individual") + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/2 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml index bcff19ad50..b1435b45d5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml @@ -220,3 +220,42 @@ is_lha_shared_accommodation_rate_specified_renter: false # 40 >= 35, but actual shared accommodation independently gives A. LHA_category: A + +- name: A disabled 17-year-old renter is excepted from the shared accommodation rate + # UC Sch 4 para 29(5)(c): a renter under 35 receiving the PIP daily living + # component is an excepted person, so the one-bedroom rate applies: B. + period: 2025 + input: + age: 17 + pip_dl: 3_000 + output: + is_lha_shared_accommodation_rate_specified_renter: false + LHA_category: B + +- name: A disabled 16-year-old renter is excepted from the shared accommodation rate + # UC Sch 4 para 29(5)(a): the DLA care component at the middle or highest + # rate makes a renter under 35 an excepted person: B. + period: 2025 + input: + age: 16 + dla_sc_middle_plus: true + output: + is_lha_shared_accommodation_rate_specified_renter: false + LHA_category: B + +- name: A 16-year-old renter without a disability benefit gets the shared rate + period: 2025 + input: + age: 16 + output: + is_lha_shared_accommodation_rate_specified_renter: true + LHA_category: A + +- name: A renter under 35 on attendance allowance is excepted from the shared rate + period: 2025 + input: + age: 30 + attendance_allowance: 3_000 + output: + is_lha_shared_accommodation_rate_specified_renter: false + LHA_category: B diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml index 3e269a5164..fcdafae7eb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml @@ -283,3 +283,55 @@ # Exemption makes the cap infinite: max(100,000 - infinity, 0) = 0. # Assert the resulting reduction because the YAML runner cannot compare infinities. benefit_cap_reduction: 0 + +- name: A Housing Benefit young person keeps the family cap though not a UC qualifying young person + # The 19-year-old started non-advanced education at 17 and is past UC's + # terminal date, so is not a UC qualifying young person (UC reg 5), but is a + # Child Benefit qualifying young person, which Housing Benefit uses (HB reg + # 19). One cap serves both, so the family rate applies: 22,020. + period: 2025 + input: + people: + parent: + age: 40 + is_parent: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + benunits: + benunit: + members: [parent, student] + is_benefit_cap_exempt: false + households: + household: + members: [parent, student] + region: SOUTH_EAST + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + benefit_cap: 22_020 + +- name: A 16-year-old outside education keeps the family cap (UC reg 5(1)(a) window) + # Every 16-year-old is a UC qualifying young person until the 1 September + # after their 16th birthday; annual ages cannot place that date. + period: 2025 + input: + people: + parent: + age: 40 + is_parent: true + teenager: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, teenager] + is_benefit_cap_exempt: false + households: + household: + members: [parent, teenager] + region: SOUTH_EAST + output: + benefit_cap: 22_020 diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py index e86210da62..5527820b67 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py @@ -7,11 +7,11 @@ class is_benefit_cap_single_claimant_rate(Variable): label = "Benefit cap single claimant rate applies" documentation = ( "Whether the claimant has no partner and is not responsible for a " - "child or qualifying young person for Universal Credit. The model " - "uses one benefit cap for Universal Credit and Housing Benefit. " - "Housing Benefit instead uses the Child Benefit young-person " - "definition. Couple status is a proxy for joint-claim status and " - "does not capture Universal Credit couples who may claim as singles." + "child or young person. The model uses one benefit cap for Universal " + "Credit and Housing Benefit, so the family rate applies if either " + "scheme's child or young-person test is met. Couple status is a proxy " + "for joint-claim status and does not capture Universal Credit couples " + "who may claim as singles." ) definition_period = YEAR reference = ( @@ -24,6 +24,6 @@ class is_benefit_cap_single_claimant_rate(Variable): def formula(benunit, period, parameters): return ~benunit("is_couple", period) & ~benunit( - "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + "is_responsible_for_child_or_young_person_for_uc_or_housing_benefit", period, ) diff --git a/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py index cda112fc86..beb914dfa9 100644 --- a/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py +++ b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py @@ -7,12 +7,11 @@ class is_lha_shared_accommodation_rate_specified_renter(Variable): label = "LHA renter restricted to the shared accommodation rate" documentation = ( "The modelled specified-renter conditions: no partner, below the shared " - "accommodation age threshold, not responsible for a UC child or " - "qualifying young person, and no non-dependant under the household " - "composition proxy. UC Schedule 4 paragraph 29 exceptions and couples " - "claiming as single people are not modelled. This category also serves " - "Housing Benefit, whose young-person definition instead follows Child " - "Benefit (HB regulation 19)." + "accommodation age threshold, not responsible for a child or young " + "person under the UC or Housing Benefit rules, no non-dependant under " + "the household composition proxy, and not excepted by a disability " + "benefit (UC Schedule 4 paragraph 29(5)). Other paragraph 29 " + "exceptions and couples claiming as single people are not modelled." ) definition_period = YEAR reference = ( @@ -26,6 +25,16 @@ class is_lha_shared_accommodation_rate_specified_renter(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.LHA + person = benunit.members + # UC Sch 4 para 29(5) / HB reg 2(1): a renter under 35 receiving + # attendance allowance, DLA care at the middle or highest rate, or the + # PIP daily living component is excepted. Other para 29 exceptions + # (care leavers, hostel residents, MAPPA, domestic abuse, modern + # slavery, foster parents) are not observed. + excepted_disabled_renter = benunit.any( + person("is_claimant_or_partner", period) + & (add(person, period, p.shared_accommodation_exception_benefits) > 0) + ) return ( ~benunit("is_couple", period) & ( @@ -33,8 +42,9 @@ def formula(benunit, period, parameters): < p.shared_accommodation_age_threshold ) & ~benunit( - "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + "is_responsible_for_child_or_young_person_for_uc_or_housing_benefit", period, ) & ~benunit("lha_renter_has_non_dependant", period) + & ~excepted_disabled_renter ) diff --git a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py new file mode 100644 index 0000000000..2897b52f3a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class is_responsible_for_child_or_young_person_for_uc_or_housing_benefit(Variable): + """Responsible for a child or young person under UC or Housing Benefit rules. + + The benefit cap and the LHA category each serve both Universal Credit and + Housing Benefit in this model. UC counts a child or qualifying young person + (UC Regs 2013 regs 4-5); Housing Benefit counts a child or young person, + whose young-person test follows Child Benefit (HB Regs 2006 regs 2, 19). + The family rates apply if either scheme's test is met, so neither scheme's + families are moved onto the single rate by the other's narrower definition. + + UC reg 5(1)(a) also makes every 16-year-old a qualifying young person until + the 1 September after their 16th birthday, whatever their education. Annual + ages cannot place that date, so a 16-year-old in the benefit unit who is not + a claimant or partner counts here; the UC child element does not yet model + reg 5(1)(a). + """ + + value_type = bool + entity = BenUnit + label = "Responsible for a child or young person (UC or Housing Benefit)" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + ) + + def formula(benunit, period, parameters): + person = benunit.members + age = person("age", period) + sixteen = (age >= 16) & (age < 17) & ~person("is_claimant_or_partner", period) + return ( + benunit( + "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", + period, + ) + | benunit( + "is_responsible_for_child_or_young_person_for_legacy_benefits", period + ) + | benunit.any(sixteen) + ) diff --git a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py index 70692b2e7a..347788ab60 100644 --- a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py +++ b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py @@ -22,8 +22,13 @@ class lha_renter_has_non_dependant(Variable): def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) - child_or_qyp = person( - "is_child_or_qualifying_young_person_for_universal_credit", period + # A child or young person under either scheme (UC reg 5; HB reg 19) + # is a dependant, not a non-dependant. + age = person("age", period) + child_or_qyp = ( + person("is_child_or_qualifying_young_person_for_universal_credit", period) + | person("is_child_or_young_person_for_legacy_benefits", period) + | ((age >= 16) & (age < 17)) ) within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) other_benefit_unit_claimants = benunit.max( From 16940d8356db396f0ef1b91cfd0bbc791a6895b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:45:44 -0400 Subject: [PATCH 16/24] Apply the Pension Credit severe disability couple rule; exclude placed children from legacy families Sch I para 1(1)(b)-(c) and reg 6(5): a couple qualifies if both partners receive a qualifying benefit and a carer benefit is paid for at most one of them (double amount with none, single with one), or if one does and the other is blind (single amount). Removing the child veto had extended the addition to couples where only one partner qualified. A child or young person placed with the family by a local authority is not a member of the household for the legacy benefits (IS reg 16(4), HB reg 21(3)). Changelog: Guarantee Credit gating and the Housing Benefit pension-age route. Co-Authored-By: Claude Opus 5.5 --- changelog.d/legacy-child-definitions.fixed.md | 2 + .../pension-credit-claimant-partner.fixed.md | 4 +- ...carer_and_severe_disability_additions.yaml | 158 +++++++++++++++++- .../is_pension_credit_eligible.yaml | 26 ++- ...ild_or_young_person_for_legacy_benefits.py | 12 +- ...e_disability_minimum_guarantee_addition.py | 43 +++-- 6 files changed, 219 insertions(+), 26 deletions(-) diff --git a/changelog.d/legacy-child-definitions.fixed.md b/changelog.d/legacy-child-definitions.fixed.md index 8fce60860e..1a2c695a57 100644 --- a/changelog.d/legacy-child-definitions.fixed.md +++ b/changelog.d/legacy-child-definitions.fixed.md @@ -1 +1,3 @@ - Updated legacy benefits to use claimant and partner roles and programme-specific child definitions for legacy benefit allowances, income disregards, capital allocation, disability premiums, Housing Benefit childcare charges, and Council Tax Reduction exemptions. +- A child or young person placed with the family by a local authority is no longer counted as a member of the family for the legacy benefits (Income Support Regulations 1987 reg 16(4); Housing Benefit Regulations 2006 reg 21(3)). +- Housing Benefit's pension-age route now tests the claimant and any partner (SI 2014/1230 reg 6A(4)), so a pensioner living with an 18- or 19-year-old dependant can make a new Housing Benefit claim. diff --git a/changelog.d/pension-credit-claimant-partner.fixed.md b/changelog.d/pension-credit-claimant-partner.fixed.md index 903cef3c4c..85e9803376 100644 --- a/changelog.d/pension-credit-claimant-partner.fixed.md +++ b/changelog.d/pension-credit-claimant-partner.fixed.md @@ -1 +1,3 @@ -- Applied Pension Credit age conditions, income assessment and carer and severe disability additions to claimants and partners rather than dependent children and young people, and removed the severe disability addition's child veto. Existing mixed-age transitional-protection and severe-disability couple/residence limitations remain. +- Applied Pension Credit age conditions, income assessment and carer and severe disability additions to claimants and partners rather than dependent children and young people, and removed the severe disability addition's child veto. +- The Pension Credit severe disability addition now follows SPC Regulations 2002 Sch I para 1(1) and reg 6(5): a claimant with a partner qualifies if both receive a qualifying disability benefit and a carer benefit is paid for at most one of them (double amount when none is paid, single amount when one is), or if one receives it, the other is blind and no carer benefit is paid for the first (single amount). The person cared for is not observed, so each carer benefit paid in the benefit unit is taken to be for a different claimant or partner. The household non-dependant residence test and the mixed-age transitional protection remain unmodelled. +- Guarantee Credit is now paid only to benefit units eligible for Pension Credit, so it can no longer act as a passport (for example to targeted childcare) for families that do not qualify. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml index 00319cb92c..f735f26eb4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml @@ -69,6 +69,7 @@ attendance_allowance: 100 partner: age: 70 + attendance_allowance: 100 care_hours: 35 carers_allowance_reported: 0 would_claim_carers_allowance: false @@ -82,12 +83,106 @@ output: receives_carer_benefit: [false, false] is_carer_for_benefits: [false, true] - # Retained model limitation: Sch I para 1(1)(b)/(c) requires both partners - # qualifying, or one qualifying and one blind. This fixture's one-addition - # expectation preserves the existing simplification; it is not the legal award. + # Both partners qualify (Sch I para 1(1)(b)) and no carer benefit is paid, + # so the double amount applies (reg 6(5)): 2 x 67.30 x 52. + severe_disability_minimum_guarantee_addition: 2 * 67.3 * 52 + +- name: Severe disability addition needs both partners to qualify + # Sch I para 1(1)(b)-(c): a claimant with a partner is severely disabled + # only if both receive a qualifying benefit, or one does and the other is + # blind. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + partner: + age: 68 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + severe_disability_minimum_guarantee_addition: 0 + +- name: Severe disability addition with one qualifying partner and a blind partner + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + partner: + age: 68 + is_blind: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + # Sch I para 1(1)(c): single amount, 67.30 x 52. severe_disability_minimum_guarantee_addition: 67.3 * 52 -- name: Severe disability addition is withheld when the partner receives Carer's Allowance +- name: A child does not rescue a couple where only one partner qualifies + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + partner: + age: 68 + child: + age: 10 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + country: ENGLAND + output: + severe_disability_minimum_guarantee_addition: 0 + +- name: Severe disability addition is withheld when Carer's Allowance is paid for the qualifying partner of a blind partner + # Sch I para 1(1)(c)(iv): no carer benefit may be paid for the partner who + # receives the qualifying benefit. The carer is assumed to care for them. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + partner: + age: 70 + is_blind: true + care_hours: 35 + carers_allowance_reported: 1 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + receives_carer_benefit: [false, true] + severe_disability_minimum_guarantee_addition: 0 + +- name: Both partners qualify and Carer's Allowance is paid for one of them + # Sch I para 1(1)(b) still holds when a carer benefit is paid for one only + # of the partners; reg 6(5)(a) then gives the single amount, 67.30 x 52. period: 2021 absolute_error_margin: 0.01 input: @@ -97,6 +192,7 @@ attendance_allowance: 100 partner: age: 70 + attendance_allowance: 100 care_hours: 35 carers_allowance_reported: 1 benunits: @@ -108,4 +204,58 @@ country: ENGLAND output: receives_carer_benefit: [false, true] + severe_disability_minimum_guarantee_addition: 67.3 * 52 + +- name: Both partners qualify and carer benefits are paid for both + # Carer benefits for both partners fail Sch I para 1(1)(b), and (c) needs a + # blind partner, so no addition. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + care_hours: 35 + carers_allowance_reported: 1 + partner: + age: 70 + attendance_allowance: 100 + care_hours: 35 + carers_allowance_reported: 1 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + receives_carer_benefit: [true, true] + severe_disability_minimum_guarantee_addition: 0 + +- name: A single claimant with a carer benefit paid for them gets no addition + # Sch I para 1(1)(a)(iii). Supplying the claimant flag replaces the formula + # for everyone, so set it for both. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + is_claimant_or_partner: true + carer: + age: 45 + care_hours: 35 + carers_allowance_reported: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, carer] + households: + household: + members: [claimant, carer] + country: ENGLAND + output: severe_disability_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml index 8940989fab..00a7b4b387 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/is_pension_credit_eligible.yaml @@ -74,24 +74,46 @@ people: claimant: age: 79 + is_claimant_or_partner: true partner: age: 19 is_in_non_advanced_education: true age_started_or_accepted_current_education_or_training: 18 + is_claimant_or_partner: true benunits: benunit: members: [claimant, partner] output: - # No identified parent: 1 of 2 claimants is pension age, so s.4(1A) bars PC. - is_claimant_or_partner: [true, true] + # An identified partner: 1 of 2 claimants is pension age, so s.4(1A) bars PC. is_pension_credit_eligible: false +- name: Without relationships, a 19-year-old living with a 79-year-old is presumed their grandchild. + period: 2026 + input: + people: + claimant: + age: 79 + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 18 + benunits: + benunit: + members: [claimant, young_person] + output: + # Under 20 and 60 years younger than the head: presumed a child of the + # household, not a partner (PolicyEngine presumption), so the pensioner + # claims alone. + is_claimant_or_partner: [true, false] + is_pension_credit_eligible: true + - name: An explicitly identified under-18 partner must meet Pension Credit age conditions. period: 2026 input: people: claimant: age: 79 + is_claimant_or_partner: true partner: age: 17 is_claimant_or_partner: true diff --git a/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py index 02d809d361..7091a57918 100644 --- a/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/is_child_or_young_person_for_legacy_benefits.py @@ -11,6 +11,8 @@ class is_child_or_young_person_for_legacy_benefits(Variable): (SSCBA 1992 s.142), excluding young persons entitled to a benefit in their own right. That is the Child Benefit child or qualifying young person test, restricted to members who are not the claimant or partner. + A child or young person placed with the claimant by a local authority is + not a member of the claimant's household (IS reg 16(4); HB reg 21(3)). """ value_type = bool @@ -24,9 +26,13 @@ class is_child_or_young_person_for_legacy_benefits(Variable): "https://www.legislation.gov.uk/uksi/1996/207/regulation/76", "https://www.legislation.gov.uk/uksi/2008/794/regulation/2", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/16", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/21", ) def formula(person, period, parameters): - return person( - "is_child_or_qualifying_young_person_for_child_benefit", period - ) & ~person("is_claimant_or_partner", period) + return ( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~person("is_claimant_or_partner", period) + & ~person("is_looked_after_by_local_authority", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 0cd16fd39e..d113b23e98 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -16,26 +16,37 @@ class severe_disability_minimum_guarantee_addition(Variable): def formula(benunit, period, parameters): # Count qualifying claimants/partners; children and qualifying young # people are ignored under Sch I para 2(2)(f). - # Retained simplifications: one addition per qualifying member, without - # para 1(1)(b)/(c)'s both-partners-or-blind-partner condition or the - # household-level non-dependant residence test. - # A carer benefit paid anywhere in the benefit unit is assumed to be - # for a claimant and blocks the award. Underlying entitlement alone - # does not block it; the person receiving care is not identified. + # Sch I para 1(1): a single claimant qualifies if no carer benefit is + # paid for them (a). A couple qualifies if both partners receive a + # qualifying benefit and a carer benefit is paid for at most one of + # them (b), or if one does, the other is blind and no carer benefit is + # paid for the first (c). Reg 6(5): the double amount applies under (b) + # when no carer benefit is paid for either partner, otherwise the + # single amount. + # Retained simplifications: the household-level non-dependant + # residence test is not modelled; the person receiving care is not + # identified, so each carer benefit paid in the benefit unit is assumed + # to be for a different claimant or partner (for the qualifying + # partner under (c)); underlying entitlement alone does not count. severe_disability = parameters( period ).gov.dwp.pension_credit.guarantee_credit.severe_disability - relevant_benefits = severe_disability.relevant_benefits person = benunit.members - person_receives_qualifying_benefits = add(person, period, relevant_benefits) > 0 claimant_or_partner = person("is_claimant_or_partner", period) - count_eligible_claimants = benunit.sum( - claimant_or_partner & person_receives_qualifying_benefits + qualifies = add(person, period, severe_disability.relevant_benefits) > 0 + qualifying = benunit.sum(claimant_or_partner & qualifies) + other_partner_blind = benunit.any( + claimant_or_partner & ~qualifies & person("is_blind", period) ) - carer_benefit_received = add(benunit, period, ["receives_carer_benefit"]) > 0 - return ( - ~carer_benefit_received - * count_eligible_claimants - * severe_disability.addition - * WEEKS_IN_YEAR + carers = add(benunit, period, ["receives_carer_benefit"]) + no_carer = carers == 0 + amounts = where( + benunit("is_couple", period), + where( + qualifying >= 2, + max_(2 - carers, 0), + (qualifying == 1) & other_partner_blind & no_carer, + ), + (qualifying >= 1) & no_carer, ) + return amounts * severe_disability.addition * WEEKS_IN_YEAR From 2de365ce670457ab91d7b21c9a1090a4aced5f7f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:11:48 -0400 Subject: [PATCH 17/24] Assess means-test income for the claimant and partner only UC regs 22 and 66, HB reg 25(3), IS reg 23(1), TCA 2002 s.7 and CTR (England) Sch 1 para 11 count the claimant's and any partner's income, not that of children, young persons or other benefit-unit members. The UC, HB, IS, tax credit and CTR income totals summed every member, so once an unrelated 18-year-old stopped being a claimant their pay still cut the parent's award. add_for_claimant_and_partner (the pattern Pension Credit income already used) now drives UC earned and unearned income, including the tax, pension and capital-derived deductions, and HB, IS, tax credit and CTR applicable income. Tests: the review's probe (parent 40, earning 18-year-old, child 10: UC equals the parent and child alone), a legacy pair, a young person's earnings case, and a Hypothesis property that income given to any non-claimant member never changes any of the seven means-test incomes. Co-Authored-By: Claude Opus 5.5 --- .../means-test-claimant-income.fixed.md | 1 + policyengine_uk/model_api.py | 1 + .../income/uc_earned_income.yaml | 6 +- ...eans_test_claimant_and_partner_income.yaml | 121 ++++++++++++++++++ .../test_means_test_income_properties.py | 116 +++++++++++++++++ policyengine_uk/utils/benefit_unit.py | 22 ++++ .../housing_benefit_applicable_income.py | 27 ++-- .../dwp/income_support_applicable_income.py | 14 +- .../gov/dwp/tax_credits_applicable_income.py | 9 +- .../income/uc_earned_income.py | 11 +- .../income/uc_unearned_income.py | 12 +- ...council_tax_reduction_applicable_income.py | 27 +++- 12 files changed, 336 insertions(+), 31 deletions(-) create mode 100644 changelog.d/means-test-claimant-income.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml create mode 100644 policyengine_uk/tests/test_means_test_income_properties.py create mode 100644 policyengine_uk/utils/benefit_unit.py diff --git a/changelog.d/means-test-claimant-income.fixed.md b/changelog.d/means-test-claimant-income.fixed.md new file mode 100644 index 0000000000..6dd9a239de --- /dev/null +++ b/changelog.d/means-test-claimant-income.fixed.md @@ -0,0 +1 @@ +- Universal Credit, Housing Benefit, Income Support, tax credits and Council Tax Reduction now assess the income of the claimant and any partner only, as Pension Credit already did. The earnings, pensions and savings income of a child, a young person or any other member of the benefit unit no longer reduce the award (UC Regulations 2013 reg 22; HB Regulations 2006 reg 25; IS Regulations 1987 reg 23; Tax Credits Act 2002 s.7; Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch 1 para 11). The shared helper is `add_for_claimant_and_partner`. diff --git a/policyengine_uk/model_api.py b/policyengine_uk/model_api.py index 367801d235..b1feb9d032 100644 --- a/policyengine_uk/model_api.py +++ b/policyengine_uk/model_api.py @@ -3,5 +3,6 @@ from policyengine_core import periods from microdf import MicroSeries, MicroDataFrame from policyengine_uk.utils.scenario import Scenario +from policyengine_uk.utils.benefit_unit import add_for_claimant_and_partner GBP = "currency-GBP" diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml index 3647f0e3d4..62a0fd3051 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml @@ -5,7 +5,8 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 10 - benunit_tax: 1 + # The claimant's own tax (UC reg 22 counts the claimant's earnings). + tax: 1 output: uc_earned_income: 1100 - 11 @@ -16,6 +17,7 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 2_000 - benunit_tax: 1 + # The claimant's own tax (UC reg 22 counts the claimant's earnings). + tax: 1 output: uc_earned_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml new file mode 100644 index 0000000000..87bab74fc1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml @@ -0,0 +1,121 @@ +# Means tests assess the income of the claimant and any partner only: not of +# children or young persons, and not of other members of the benefit unit +# (UC Regs 2013 reg 22; HB Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 +# s.7; CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11). + +- name: An earning 18-year-old who is neither partner nor dependant does not reduce the parent's UC + # Default inputs: the 18-year-old is presumed the parent's child, is not in + # education (so not a UC qualifying young person) and is not a claimant. + # UC is the same as for the parent and the 10-year-old alone (next case). + period: 2026 + absolute_error_margin: 1 + input: + people: + parent: + age: 40 + young_adult: + age: 18 + employment_income: 15_000 + child: + age: 10 + benunits: + benunit: + members: [parent, young_adult, child] + households: + household: + members: [parent, young_adult, child] + output: + is_claimant_or_partner: [true, false, false] + uc_earned_income: 0 + universal_credit: 9_321.36 + +- name: The same parent and child without the 18-year-old + period: 2026 + absolute_error_margin: 1 + input: + people: + parent: + age: 40 + child: + age: 10 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + universal_credit: 9_321.36 + +- name: A qualifying young person's earnings are not UC income + # UC reg 22 deducts the claimant's earned income only. + period: 2026 + absolute_error_margin: 1 + input: + people: + parent: + age: 40 + student: + age: 17 + current_education: UPPER_SECONDARY + employment_income: 5_000 + child: + age: 10 + benunits: + benunit: + members: [parent, student, child] + households: + household: + members: [parent, student, child] + output: + is_child_or_qualifying_young_person_for_universal_credit: [false, true, true] + uc_earned_income: 0 + +- name: A non-dependant 19-year-old's pay is not the claimant's income under any legacy means test + # The claimant earns 10,000 and a 19-year-old presumed their child (not in + # education, so not a young person) earns 15,000. Every applicable income + # equals the claimant-alone case below. + period: 2024 + absolute_error_margin: 1 + input: + people: + claimant: + age: 40 + employment_income: 10_000 + young_adult: + age: 19 + employment_income: 15_000 + benunits: + benunit: + members: [claimant, young_adult] + households: + household: + members: [claimant, young_adult] + output: + is_claimant_or_partner: [true, false] + housing_benefit_applicable_income: 9_655.42 + income_support_applicable_income: 9_740 + tax_credits_applicable_income: 10_000 + council_tax_reduction_applicable_income: 10_000 + uc_earned_income: 10_000 + +- name: The same claimant alone + period: 2024 + absolute_error_margin: 1 + input: + people: + claimant: + age: 40 + employment_income: 10_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + housing_benefit_applicable_income: 9_655.42 + income_support_applicable_income: 9_740 + tax_credits_applicable_income: 10_000 + council_tax_reduction_applicable_income: 10_000 + uc_earned_income: 10_000 diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py new file mode 100644 index 0000000000..83af2fcb0f --- /dev/null +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -0,0 +1,116 @@ +"""Means tests count the claimant's and partner's income, nobody else's. + +Invariant: for any benefit unit, giving income to a member who is neither the +claimant nor the partner (a child, a young person, or another adult in the +benefit unit) never changes a means test's income (UC Regs 2013 reg 22; HB +Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 s.7; CTR (Prescribed +Requirements) (England) Regs 2012 Sch 1 para 11; SPCA 2002 s.5). + +Each example builds families twice in one simulation: once with the other +member's income and once without, in separate households and benefit units. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2024 +INCOME_TESTS = [ + "uc_earned_income", + "uc_unearned_income", + "housing_benefit_applicable_income", + "income_support_applicable_income", + "tax_credits_applicable_income", + "council_tax_reduction_applicable_income", + "pension_credit_income", +] +EDUCATIONS = ["NOT_IN_EDUCATION", "UPPER_SECONDARY", "TERTIARY"] +money = st.floats(0, 50_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def families(draw): + # A head, an optional partner, and one member who by construction is + # neither claimant nor partner: a child under 16, a 16-19-year-old at + # least 16 years younger than the head (presumed the head's child), or a + # third adult no older than the partner (the partner, earlier in member + # order, wins any age tie). + kind = draw(st.sampled_from(["child", "presumed_child", "third_adult"])) + if kind == "child": + other_age = draw(st.integers(0, 15)) + head_age = draw(st.integers(25, 85)) + elif kind == "presumed_child": + other_age = draw(st.integers(16, 19)) + head_age = draw(st.integers(other_age + 16, 85)) + else: + head_age = draw(st.integers(25, 85)) + family = [ + { + "age": head_age, + "employment_income": draw(money), + "private_pension_income": draw(money), + } + ] + if kind == "third_adult" or draw(st.booleans()): + partner_age = draw(st.integers(20, head_age)) + family.append({"age": partner_age, "employment_income": draw(money)}) + if kind == "third_adult": + other_age = draw(st.integers(20, partner_age)) + family.append( + { + "age": other_age, + "current_education": draw(st.sampled_from(EDUCATIONS)), + "employment_income": draw(st.floats(1, 50_000)), + "private_pension_income": draw(money), + "savings_interest_income": draw(money), + } + ) + return family + + +def situation(families_): + people, benunits, households = {}, {}, {} + for i, family in enumerate(families_): + names = [] + for j, inputs in enumerate(family): + name = f"p{i}_{j}" + people[name] = {k: {YEAR: v} for k, v in inputs.items()} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = {"members": names} + return {"people": people, "benunits": benunits, "households": households} + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(families(), min_size=1, max_size=6)) +def test_income_of_non_claimants_never_counts(units): + without = [ + family[:-1] + + [ + { + **family[-1], + "employment_income": 0, + "private_pension_income": 0, + "savings_interest_income": 0, + } + ] + for family in units + ] + sim = Simulation(situation=situation(units + without)) + claimant = sim.calculate("is_claimant_or_partner", YEAR) + offsets = np.cumsum([0] + [len(f) for f in units]) + n = len(units) + for i in range(n): + # The generator's construction, checked against the model. + assert not claimant[offsets[i + 1] - 1], units[i] + for variable in INCOME_TESTS: + values = sim.calculate(variable, YEAR) + for i in range(n): + assert abs(values[i] - values[n + i]) < 0.01, (variable, units[i]) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py new file mode 100644 index 0000000000..211c3828e9 --- /dev/null +++ b/policyengine_uk/utils/benefit_unit.py @@ -0,0 +1,22 @@ +"""Benefit-unit aggregation helpers for means tests.""" + + +def add_for_claimant_and_partner(benunit, period, variables): + """Sum variables over the benefit unit's claimant and partner. + + Means tests assess the income of the claimant and any partner, not of + children, young persons or other members of the benefit unit (UC Regs + 2013 reg 22; HB Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 s.7; + CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11). + Person-level variables are summed over members flagged + ``is_claimant_or_partner``; benefit-unit variables are added as they are. + """ + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + total = 0 + for name in variables: + if person.entity.get_variable(name).entity.is_person: + total = total + benunit.sum(person(name, period) * claimant_or_partner) + else: + total = total + benunit(name, period) + return total diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 999da75d2a..39cb70d6df 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -34,20 +34,31 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - # Add personal benefits, credits and total benefits to income - benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) - income = add(benunit, period, INCOME_COMPONENTS) - personal_benefits = add(benunit, period, PERSONAL_BENEFITS) - credits = add(benunit, period, ["tax_credits"]) + # Add personal benefits, credits and total benefits to income. Only + # the claimant's and partner's income counts; a child's or young + # person's does not (HB Regs 2006 reg 25). + benefits = add_for_claimant_and_partner( + benunit, period, BENUNIT_MEANS_TESTED_BENEFITS + ) + income = add_for_claimant_and_partner(benunit, period, INCOME_COMPONENTS) + personal_benefits = add_for_claimant_and_partner( + benunit, period, PERSONAL_BENEFITS + ) + credits = add_for_claimant_and_partner(benunit, period, ["tax_credits"]) increased_income = income + personal_benefits + credits + benefits if not bi.interactions.include_in_means_tests: # Basic income is already in personal benefits, deduct if needed - increased_income -= add(benunit, period, ["basic_income"]) + increased_income -= add_for_claimant_and_partner( + benunit, period, ["basic_income"] + ) # Reduce increased income by pension contributions and tax - pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 + pension_contributions = ( + add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) + * 0.5 + ) TAX_COMPONENTS = ["income_tax", "national_insurance"] - tax = add(benunit, period, TAX_COMPONENTS) + tax = add_for_claimant_and_partner(benunit, period, TAX_COMPONENTS) increased_income_reduced_by_tax_and_pensions = ( increased_income - tax - pension_contributions ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 9f20ae70e8..38d3a51377 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -20,16 +20,22 @@ def formula(benunit, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: INCOME_COMPONENTS.append("basic_income") - income = add(benunit, period, INCOME_COMPONENTS) - tax = add( + # The claimant's and partner's income only (IS Regs 1987 reg 23). + income = add_for_claimant_and_partner(benunit, period, INCOME_COMPONENTS) + tax = add_for_claimant_and_partner( benunit, period, ["income_tax", "national_insurance"], ) - income += add(benunit, period, ["social_security_income"]) + income += add_for_claimant_and_partner( + benunit, period, ["social_security_income"] + ) income += benunit("income_support_tariff_income", period) income -= tax - income -= add(benunit, period, ["pension_contributions"]) * 0.5 + income -= ( + add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) + * 0.5 + ) # Schedule 8 paras 5, 6 and 10 use mutually exclusive claimant types. mt = IS.means_test single = benunit("is_single_person", period) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index d2d67ed018..27c3a39535 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -19,7 +19,8 @@ def formula(benunit, period, parameters): "dividend_income", "property_income", ] - income = add(benunit, period, STEP_1_COMPONENTS) + # The income of the claimant, or of both joint claimants (TCA 2002 s.7). + income = add_for_claimant_and_partner(benunit, period, STEP_1_COMPONENTS) income = max_(income - TC.means_test.non_earned_disregard, 0) STEP_2_COMPONENTS = [ "employment_income", @@ -30,7 +31,9 @@ def formula(benunit, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") - income += add(benunit, period, STEP_2_COMPONENTS) + income += add_for_claimant_and_partner(benunit, period, STEP_2_COMPONENTS) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] - on_exempt_benefits = add(benunit, period, EXEMPT_BENEFITS) > 0 + on_exempt_benefits = ( + add_for_claimant_and_partner(benunit, period, EXEMPT_BENEFITS) > 0 + ) return income * ~on_exempt_benefits diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 8ff40e4a84..0a55cd4229 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -8,13 +8,18 @@ class uc_earned_income(Variable): definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/22" + def formula(benunit, period, parameters): - personal_gross_earned_income = add( + # Only the claimant's (or joint claimants') earned income counts + # (UC Regs 2013 reg 22(1)(b)), with their own tax and pension + # contributions deducted. + personal_gross_earned_income = add_for_claimant_and_partner( benunit, period, ["uc_mif_capped_earned_income"] ) - disregards = add( + disregards = add_for_claimant_and_partner( benunit, period, - ["uc_work_allowance", "benunit_tax", "pension_contributions"], + ["uc_work_allowance", "tax", "pension_contributions"], ) return max_(0, personal_gross_earned_income - disregards) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py index 8dc07ee660..eb2aedd5a3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py @@ -11,7 +11,11 @@ class uc_unearned_income(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test household = benunit.household - total = add(benunit, period, p.income_definitions.unearned) + # Only the claimant's (or joint claimants') unearned income counts + # (UC Regs 2013 reg 22(1)(a)). + total = add_for_claimant_and_partner( + benunit, period, p.income_definitions.unearned + ) tariff_income_applies = benunit("uc_tariff_income", period) > 0 reported_capital = benunit("uc_reported_capital", period) has_reported_capital = reported_capital >= 0 @@ -20,10 +24,10 @@ def formula(benunit, period, parameters): ) + household("non_residential_property_value", period) capital_derived_income = ( ((household("savings", period) > 0) | has_reported_capital) - * benunit("savings_interest_income", period) + * add_for_claimant_and_partner(benunit, period, ["savings_interest_income"]) + ((household("corporate_wealth", period) > 0) | has_reported_capital) - * benunit("dividend_income", period) + * add_for_claimant_and_partner(benunit, period, ["dividend_income"]) + ((property_capital > 0) | has_reported_capital) - * benunit("property_income", period) + * add_for_claimant_and_partner(benunit, period, ["property_income"]) ) return total - tariff_income_applies * capital_derived_income diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 8cf5f764b8..64b8a53f8b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -33,15 +33,28 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - benefits = add(benunit, period, benunit_means_tested_benefits) - income = add(benunit, period, income_components) - personal_benefit_income = add(benunit, period, personal_benefits) - credits = add(benunit, period, ["tax_credits"]) + # The applicant's and partner's income only (CTR (Prescribed + # Requirements) (England) Regs 2012 Sch 1 para 11). + benefits = add_for_claimant_and_partner( + benunit, period, benunit_means_tested_benefits + ) + income = add_for_claimant_and_partner(benunit, period, income_components) + personal_benefit_income = add_for_claimant_and_partner( + benunit, period, personal_benefits + ) + credits = add_for_claimant_and_partner(benunit, period, ["tax_credits"]) increased_income = income + personal_benefit_income + credits + benefits if not bi.interactions.include_in_means_tests: - increased_income -= add(benunit, period, ["basic_income"]) + increased_income -= add_for_claimant_and_partner( + benunit, period, ["basic_income"] + ) - pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 - tax = add(benunit, period, ["income_tax", "national_insurance"]) + pension_contributions = ( + add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) + * 0.5 + ) + tax = add_for_claimant_and_partner( + benunit, period, ["income_tax", "national_insurance"] + ) return max_(0, increased_income - tax - pension_contributions) From 229bbfb3155df43aadfd881169ceba8a1a34a212 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:11:48 -0400 Subject: [PATCH 18/24] Make the eldest adult the claimant when the head is not one; flagged parents form the couple When the benefit-unit head is not an HBAI adult the eldest adult is the claimant. When the claimant is not flagged as a parent but two other members are, those two are the couple (parents in a grandparent's benefit unit). The property-test reference and the adult-head invariant follow the rule. Co-Authored-By: Claude Opus 5.5 --- .../generic-child-adult-flags.fixed.md | 2 +- ..._child_and_adult_definitions_properties.py | 55 ++++++++++++------- .../demographic/is_claimant_or_partner.py | 44 ++++++++++----- 3 files changed, 65 insertions(+), 36 deletions(-) diff --git a/changelog.d/generic-child-adult-flags.fixed.md b/changelog.d/generic-child-adult-flags.fixed.md index 3218a3361d..bff79738d9 100644 --- a/changelog.d/generic-child-adult-flags.fixed.md +++ b/changelog.d/generic-child-adult-flags.fixed.md @@ -1,4 +1,4 @@ - Fixed benefit-unit structure: a lone parent living with an 18- or 19-year-old still at school is no longer treated as a (married) couple, a couple with such a dependant is no longer treated as single or a lone parent, and a 16- or 17-year-old heading their own benefit unit is a single claimant rather than a couple with themselves as the child. Lone parent, single claimant and couple are now mutually exclusive. -- A benefit unit now has at most two claimants or partners: the head and one partner (a member flagged as a parent if there is one, otherwise the eldest other adult). Without relationship inputs, a member under 20 and at least 16 years younger than the head is presumed to be the head's child rather than their partner, so a lone parent with an 18-year-old at university stays single and a couple with one stays a couple of two. Datasets and users can supply `is_claimant_or_partner` directly. +- A benefit unit now has at most two claimants or partners: the head (or the eldest adult if the head is not an adult) and one partner (a member flagged as a parent if there is one, otherwise the eldest other adult). When the head is not flagged as a parent but two other members are, those two are the couple. Without relationship inputs, a member under 20 and at least 16 years younger than the head is presumed to be the head's child rather than their partner, so a lone parent with an 18-year-old at university stays single and a couple with one stays a couple of two. Datasets and users can supply `is_claimant_or_partner` directly. - Fixed Marriage Allowance and the CPS marriage reforms to transfer allowance and split income only between spouses or civil partners, never from a child in the benefit unit; the Tax-Free Childcare work condition to test the applicant and partner only; the WTC lone parent, couple, disability and childcare elements and eligibility routes to use the tax credit claimant and child definitions; and the DfE schemes (Care to Learn, Childcare Grant, Parents' Learning Allowance, extended childcare income test) to use each scheme's own definition of a child or of the parent and partner. - The HBAI equivalence scale now weights HBAI adults and dependent children, which changes factors only in households with no one aged 18 or over. diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index ad125febbf..b6a5944837 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -14,7 +14,8 @@ has an HBAI adult; valid families (one or two adults aged 20+ and dependants) have exactly their adults. Couple/single and couple/lone-parent/single-person partition benefit units. Heads aged 16+ - are claimants or partners, including when explicitly younger than others. + are claimants or partners, including when explicitly younger than others, + unless two other members are flagged parents and the head is not. 3. All 23 deprecated shims retain their original age-18 formulas, including empty-group sentinels and historical eldest-child tie behaviour. Explicit age_under_18 has the same membership as the shims for school-attendance @@ -215,26 +216,32 @@ def hbai_dependent_child(p, family): def claimants_or_partners(family): - # is_claimant_or_partner's documented rule: an HBAI-adult head, plus one - # partner: the eldest other HBAI adult flagged as a parent, else the - # eldest other HBAI adult not presumed the head's child (under 20 and at - # least 16 years younger than the head). + # is_claimant_or_partner's documented rule. The claimant is the head if an + # HBAI adult, else the eldest HBAI adult. The partner is the eldest other + # flagged parent, else the eldest other adult not presumed the claimant's + # child (under 20 and at least 16 years younger). If the claimant is not a + # flagged parent but two or more others are, the two eldest of those are + # the couple instead. Age ties go to the earlier member. + n = len(family) adult = [not hbai_dependent_child(p, family) for p in family] - head_ages = [p["age"] for p in family if p["is_benunit_head"]] - head_age = max(head_ages) if head_ages else -np.inf - others = [i for i, p in enumerate(family) if adult[i] and not p["is_benunit_head"]] - parents = [i for i in others if family[i]["is_parent"]] - not_presumed_children = [ + ages = [p["age"] for p in family] + heads = [i for i in range(n) if family[i]["is_benunit_head"] and adult[i]] + adults = [i for i in range(n) if adult[i]] + if not adults: + return [False] * n + claimant = heads[0] if heads else max(adults, key=lambda i: (ages[i], -i)) + parent = [adult[i] and family[i]["is_parent"] for i in range(n)] + other_parents = [i for i in range(n) if parent[i] and i != claimant] + if len(other_parents) >= 2 and not parent[claimant]: + couple = sorted(other_parents, key=lambda i: (-ages[i], i))[:2] + return [i in couple for i in range(n)] + pool = other_parents or [ i - for i in others - if not (family[i]["age"] < 20 and head_age - family[i]["age"] >= 16) - ] - pool = parents or not_presumed_children - partner = max(pool, key=lambda i: (family[i]["age"], -i)) if pool else None - return [ - (a and p["is_benunit_head"]) or i == partner - for i, (p, a) in enumerate(zip(family, adult)) + for i in adults + if i != claimant and not (ages[i] < 20 and ages[claimant] - ages[i] >= 16) ] + partner = max(pool, key=lambda i: (ages[i], -i)) if pool else None + return [i == claimant or i == partner for i in range(n)] def legacy_child_or_young_person(p, claimant): @@ -386,9 +393,17 @@ def assert_structure(sim, families, year=YEAR): for p, claimant in zip(family, expected) ) assert lone[i] == (sum(expected) < 2 and responsible) - for p, claimant in zip(family, actual): + # An adult head is the claimant unless two other flagged parents form + # the couple. + parents = [ + a and p["is_parent"] and not p["is_benunit_head"] + for p, a in zip(family, adults) + ] + for p, adult, claimant in zip(family, adults, actual): if p["is_benunit_head"] and p["age"] >= 16: - assert claimant + assert adult + if sum(parents) < 2 or p["is_parent"]: + assert claimant offset += len(family) diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py index 7999456f6f..264f3b43ed 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -16,13 +16,18 @@ class is_claimant_or_partner(Variable): adult or a couple plus dependent children. - Only HBAI adults (`is_hbai_adult`) can be a claimant or partner. - - The benefit-unit head is the claimant. - - The partner is one other member: the eldest member flagged as a parent - (`is_parent`) if there is one, otherwise the eldest other adult who is - not presumed to be the head's child. A member under 20 and at least 16 - years younger than the head is presumed to be their child (a PolicyEngine - presumption for households entered without relationships). Any further - adults are neither claimant nor partner. + - The claimant is the benefit-unit head, or the eldest adult if the head + is not an adult. + - The partner is one other member: the eldest other member flagged as a + parent (`is_parent`) if there is one, otherwise the eldest other adult + who is not presumed to be the claimant's child. A member under 20 and at + least 16 years younger than the claimant is presumed to be their child + (a PolicyEngine presumption for households entered without + relationships). + - If the claimant is not flagged as a parent but two or more other members + are, the two eldest of those are the claimant and partner instead (for + example parents living in a grandparent's benefit unit). + - Any further adults are neither claimant nor partner. """ value_type = bool @@ -41,14 +46,23 @@ def formula(person, period, parameters): age = person("age", period) adult = person("is_hbai_adult", period) is_head = person("is_benunit_head", period) - head_age = person.benunit.max(where(is_head, age, -np.inf)) - presumed_child_of_head = (age < p.age_limit) & ( - head_age - age >= p.minimum_age_gap + head_is_adult = person.benunit.any(is_head & adult) + eldest_adult = adult & ( + person.get_rank(person.benunit, -age, condition=adult) == 0 ) - identified_partner = adult & ~is_head & person("is_parent", period) - other_adult = adult & ~is_head & ~presumed_child_of_head - pool = where( - person.benunit.any(identified_partner), identified_partner, other_adult + claimant = where(head_is_adult, is_head & adult, eldest_adult) + claimant_age = person.benunit.max(where(claimant, age, -np.inf)) + identified_parent = adult & person("is_parent", period) + other_parent = identified_parent & ~claimant + # Two flagged parents other than a non-parent claimant are the couple. + parents_are_couple = (person.benunit.sum(other_parent) >= 2) & ~( + person.benunit.any(claimant & identified_parent) ) + parent_couple = other_parent & ( + person.get_rank(person.benunit, -age, condition=other_parent) < 2 + ) + presumed_child = (age < p.age_limit) & (claimant_age - age >= p.minimum_age_gap) + other_adult = adult & ~claimant & ~presumed_child + pool = where(person.benunit.any(other_parent), other_parent, other_adult) partner = pool & (person.get_rank(person.benunit, -age, condition=pool) == 0) - return (is_head & adult) | partner + return where(parents_are_couple, parent_couple, claimant | partner) From 9b135036d97c2f2ad3fb97b8736d77d5cb0ec3f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:11:48 -0400 Subject: [PATCH 19/24] Never bar the severe disability addition by its recipient's own carer benefit; cite only UC Sch 4 para 29(5) Sch I para 1(1)(a)(iii) and (c)(iv) bar a carer benefit paid for caring for the qualifying person, so a single claimant's, or the qualifying partner's, own Carer's Allowance no longer bars the addition. HB reg 2(1)'s young individual has no disability exception: the parameter, docstring and changelog now cite UC Sch 4 para 29(5) only and say the shared LHA category applies it to HB renters too. Co-Authored-By: Claude Opus 5.5 --- ...-benefit-cap-claimant-definitions.fixed.md | 2 +- .../pension-credit-claimant-partner.fixed.md | 2 +- ...ared_accommodation_exception_benefits.yaml | 2 - ...carer_and_severe_disability_additions.yaml | 50 +++++++++++++++++++ ...red_accommodation_rate_specified_renter.py | 6 ++- ...e_disability_minimum_guarantee_addition.py | 24 +++++---- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md index a11ecd5885..6366cbbe64 100644 --- a/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md +++ b/changelog.d/lha-benefit-cap-claimant-definitions.fixed.md @@ -1,2 +1,2 @@ - Replaced age-18 child/adult flags in the LHA shared accommodation rate and the benefit-cap rates with claimant/partner status and responsibility for a child or young person. Because one LHA category and one benefit cap serve both Universal Credit and Housing Benefit, the family rates apply if either scheme's test is met (UC regs 4-5, including the reg 5(1)(a) route for 16-year-olds; HB reg 19), via the new `is_responsible_for_child_or_young_person_for_uc_or_housing_benefit`. -- Renters under 35 receiving attendance allowance, the DLA care component at the middle or highest rate, or the PIP daily living component are now excepted from the shared accommodation rate (UC Regulations 2013 Sch 4 para 29(5); HB Regulations 2006 reg 2(1)). The other paragraph 29 exceptions and the conservative LHA non-dependant proxy remain as documented limits. +- Renters under 35 receiving attendance allowance, the DLA care component at the middle or highest rate, or the PIP daily living component are now excepted from the shared accommodation rate (UC Regulations 2013 Sch 4 para 29(5)). The exception is UC's; the Housing Benefit "young individual" definition (HB Regulations 2006 reg 2(1)) has no disability exception, but the model uses one LHA category for both schemes. The other paragraph 29 exceptions and the conservative LHA non-dependant proxy remain as documented limits. diff --git a/changelog.d/pension-credit-claimant-partner.fixed.md b/changelog.d/pension-credit-claimant-partner.fixed.md index 85e9803376..6002399a75 100644 --- a/changelog.d/pension-credit-claimant-partner.fixed.md +++ b/changelog.d/pension-credit-claimant-partner.fixed.md @@ -1,3 +1,3 @@ - Applied Pension Credit age conditions, income assessment and carer and severe disability additions to claimants and partners rather than dependent children and young people, and removed the severe disability addition's child veto. -- The Pension Credit severe disability addition now follows SPC Regulations 2002 Sch I para 1(1) and reg 6(5): a claimant with a partner qualifies if both receive a qualifying disability benefit and a carer benefit is paid for at most one of them (double amount when none is paid, single amount when one is), or if one receives it, the other is blind and no carer benefit is paid for the first (single amount). The person cared for is not observed, so each carer benefit paid in the benefit unit is taken to be for a different claimant or partner. The household non-dependant residence test and the mixed-age transitional protection remain unmodelled. +- The Pension Credit severe disability addition now follows SPC Regulations 2002 Sch I para 1(1) and reg 6(5): a claimant with a partner qualifies if both receive a qualifying disability benefit and a carer benefit is paid for at most one of them (double amount when none is paid, single amount when one is), or if one receives it, the other is blind and no carer benefit is paid for the first (single amount). The person cared for is not observed, so each carer benefit paid in the benefit unit is taken to be for a different claimant or partner, and never for its recipient (a qualifying claimant's own Carer's Allowance does not bar them). The household non-dependant residence test and the mixed-age transitional protection remain unmodelled. - Guarantee Credit is now paid only to benefit units eligible for Pension Credit, so it can no longer act as a passport (for example to targeted childcare) for families that do not qualify. diff --git a/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml b/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml index 0f8aca9183..26f65c2490 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/shared_accommodation_exception_benefits.yaml @@ -10,5 +10,3 @@ metadata: reference: - title: Universal Credit Regulations 2013 Sch 4 para 29(5) href: https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/29 - - title: Housing Benefit Regulations 2006 reg 2(1) ("young individual") - href: https://www.legislation.gov.uk/uksi/2006/213/regulation/2 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml index f735f26eb4..4c8758bcbb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/carer_and_severe_disability_additions.yaml @@ -259,3 +259,53 @@ country: ENGLAND output: severe_disability_minimum_guarantee_addition: 0 + +- name: A single claimant's own Carer's Allowance does not bar the addition + # Sch I para 1(1)(a)(iii) bars an allowance paid "in respect of caring for + # him"; the claimant's own award is for caring for someone else. 67.30 x 52. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + care_hours: 35 + carers_allowance_reported: 1 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + receives_carer_benefit: true + severe_disability_minimum_guarantee_addition: 67.3 * 52 + +- name: The qualifying partner's own Carer's Allowance does not bar head (c) + # The partner receiving attendance allowance cares for the blind partner; + # (c)(iv) bars only a carer benefit paid for caring for the qualifying + # partner. Single amount, 67.30 x 52. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + attendance_allowance: 100 + care_hours: 35 + carers_allowance_reported: 1 + partner: + age: 70 + is_blind: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + receives_carer_benefit: [true, false] + severe_disability_minimum_guarantee_addition: 67.3 * 52 diff --git a/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py index beb914dfa9..2e43025fa3 100644 --- a/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py +++ b/policyengine_uk/variables/gov/dwp/is_lha_shared_accommodation_rate_specified_renter.py @@ -11,7 +11,9 @@ class is_lha_shared_accommodation_rate_specified_renter(Variable): "person under the UC or Housing Benefit rules, no non-dependant under " "the household composition proxy, and not excepted by a disability " "benefit (UC Schedule 4 paragraph 29(5)). Other paragraph 29 " - "exceptions and couples claiming as single people are not modelled." + "exceptions and couples claiming as single people are not modelled. " + "The same category serves Housing Benefit, whose young-individual " + "definition (HB regulation 2(1)) has no disability exception." ) definition_period = YEAR reference = ( @@ -26,7 +28,7 @@ class is_lha_shared_accommodation_rate_specified_renter(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.LHA person = benunit.members - # UC Sch 4 para 29(5) / HB reg 2(1): a renter under 35 receiving + # UC Sch 4 para 29(5): a renter under 35 receiving # attendance allowance, DLA care at the middle or highest rate, or the # PIP daily living component is excepted. Other para 29 exceptions # (care leavers, hostel residents, MAPPA, domestic abuse, modern diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index d113b23e98..212c027744 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -24,29 +24,35 @@ def formula(benunit, period, parameters): # when no carer benefit is paid for either partner, otherwise the # single amount. # Retained simplifications: the household-level non-dependant - # residence test is not modelled; the person receiving care is not - # identified, so each carer benefit paid in the benefit unit is assumed - # to be for a different claimant or partner (for the qualifying - # partner under (c)); underlying entitlement alone does not count. + # residence test is not modelled; the person cared for is not + # identified, so each carer benefit paid in the benefit unit is taken + # to be for a different claimant or partner, never for its recipient + # (so a qualifying claimant's own carer benefit does not bar them); + # a UC carer element paid to someone outside the benefit unit is not + # seen; underlying entitlement alone does not count. severe_disability = parameters( period ).gov.dwp.pension_credit.guarantee_credit.severe_disability person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) qualifies = add(person, period, severe_disability.relevant_benefits) > 0 - qualifying = benunit.sum(claimant_or_partner & qualifies) + qualifier = claimant_or_partner & qualifies + qualifying = benunit.sum(qualifier) other_partner_blind = benunit.any( claimant_or_partner & ~qualifies & person("is_blind", period) ) - carers = add(benunit, period, ["receives_carer_benefit"]) - no_carer = carers == 0 + receives_carer_benefit = person("receives_carer_benefit", period) + carers = benunit.sum(receives_carer_benefit) + # Heads (a) and (c): carer benefits paid to anyone but the qualifying + # claimant or partner. + carers_for_qualifier = benunit.sum(receives_carer_benefit & ~qualifier) amounts = where( benunit("is_couple", period), where( qualifying >= 2, max_(2 - carers, 0), - (qualifying == 1) & other_partner_blind & no_carer, + (qualifying == 1) & other_partner_blind & (carers_for_qualifier == 0), ), - (qualifying >= 1) & no_carer, + (qualifying >= 1) & (carers_for_qualifier == 0), ) return amounts * severe_disability.addition * WEEKS_IN_YEAR From 2b635faeab9b82848409c288d55817eee0edd7ee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:23:17 -0400 Subject: [PATCH 20/24] Keep counting each programme's own dependants' income; drop only non-members' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excluding dependants' own income too (as the regulations require) raised UC by about £4.3bn in 2026 on the enhanced FRS, because some survey records give children adult-level incomes. The means tests now count the claimant, the partner and the programme's own children or young persons, the members main already counted, and drop anyone else, which is the review's fix. Dropping dependants' income is a follow-up that needs the data fixed first. add_for_members replaces add_for_claimant_and_partner. The YAML cases and the Hypothesis property cover members outside every programme's family. Co-Authored-By: Claude Opus 5.5 --- .../means-test-claimant-income.fixed.md | 1 - .../means-test-income-members.fixed.md | 1 + policyengine_uk/model_api.py | 2 +- ...me.yaml => means_test_income_members.yaml} | 22 ++++----- .../test_means_test_income_properties.py | 45 +++++++++++-------- policyengine_uk/utils/benefit_unit.py | 19 ++++---- .../housing_benefit_applicable_income.py | 33 +++++++------- .../dwp/income_support_applicable_income.py | 24 +++++----- .../gov/dwp/tax_credits_applicable_income.py | 15 +++++-- .../income/uc_earned_income.py | 18 +++++--- .../income/uc_unearned_income.py | 20 +++++---- ...council_tax_reduction_applicable_income.py | 34 ++++++++------ 12 files changed, 134 insertions(+), 100 deletions(-) delete mode 100644 changelog.d/means-test-claimant-income.fixed.md create mode 100644 changelog.d/means-test-income-members.fixed.md rename policyengine_uk/tests/policy/baseline/gov/dwp/{means_test_claimant_and_partner_income.yaml => means_test_income_members.yaml} (82%) diff --git a/changelog.d/means-test-claimant-income.fixed.md b/changelog.d/means-test-claimant-income.fixed.md deleted file mode 100644 index 6dd9a239de..0000000000 --- a/changelog.d/means-test-claimant-income.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Universal Credit, Housing Benefit, Income Support, tax credits and Council Tax Reduction now assess the income of the claimant and any partner only, as Pension Credit already did. The earnings, pensions and savings income of a child, a young person or any other member of the benefit unit no longer reduce the award (UC Regulations 2013 reg 22; HB Regulations 2006 reg 25; IS Regulations 1987 reg 23; Tax Credits Act 2002 s.7; Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch 1 para 11). The shared helper is `add_for_claimant_and_partner`. diff --git a/changelog.d/means-test-income-members.fixed.md b/changelog.d/means-test-income-members.fixed.md new file mode 100644 index 0000000000..783a52259e --- /dev/null +++ b/changelog.d/means-test-income-members.fixed.md @@ -0,0 +1 @@ +- Universal Credit, Housing Benefit, Income Support, tax credit and Council Tax Reduction means tests now count only the income of the claimant, any partner and the programme's own children or young persons, the members the model already counted. The income of any other member of the benefit unit no longer counts: for example an 18- or 19-year-old who is presumed to be the claimant's child but is neither a partner nor a qualifying young person. The shared helper is `add_for_members`. The regulations count only the claimant's and partner's income (UC Regulations 2013 reg 22; HB Regulations 2006 reg 25; IS Regulations 1987 reg 23; Tax Credits Act 2002 s.7; Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch 1 para 11). Dropping dependants' own income as well is left to a follow-up, because some survey records give children adult-level incomes. diff --git a/policyengine_uk/model_api.py b/policyengine_uk/model_api.py index b1feb9d032..597de7c32c 100644 --- a/policyengine_uk/model_api.py +++ b/policyengine_uk/model_api.py @@ -3,6 +3,6 @@ from policyengine_core import periods from microdf import MicroSeries, MicroDataFrame from policyengine_uk.utils.scenario import Scenario -from policyengine_uk.utils.benefit_unit import add_for_claimant_and_partner +from policyengine_uk.utils.benefit_unit import add_for_members GBP = "currency-GBP" diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml similarity index 82% rename from policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml rename to policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml index 87bab74fc1..3b15d3be88 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_claimant_and_partner_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml @@ -1,7 +1,7 @@ -# Means tests assess the income of the claimant and any partner only: not of -# children or young persons, and not of other members of the benefit unit -# (UC Regs 2013 reg 22; HB Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 -# s.7; CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11). +# Means tests count the income of the claimant and any partner and, as the +# model did before, of the programme's own children or young persons. A member +# who is none of these (for example an 18- or 19-year-old presumed to be the +# claimant's child but not in education) does not count. - name: An earning 18-year-old who is neither partner nor dependant does not reduce the parent's UC # Default inputs: the 18-year-old is presumed the parent's child, is not in @@ -47,28 +47,28 @@ output: universal_credit: 9_321.36 -- name: A qualifying young person's earnings are not UC income - # UC reg 22 deducts the claimant's earned income only. +- name: A 17-year-old outside education is not a UC young person, so their pay does not count period: 2026 absolute_error_margin: 1 input: people: parent: age: 40 - student: + teenager: age: 17 - current_education: UPPER_SECONDARY + current_education: NOT_IN_EDUCATION employment_income: 5_000 child: age: 10 benunits: benunit: - members: [parent, student, child] + members: [parent, teenager, child] households: household: - members: [parent, student, child] + members: [parent, teenager, child] output: - is_child_or_qualifying_young_person_for_universal_credit: [false, true, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false, true] + is_claimant_or_partner: [true, false, false] uc_earned_income: 0 - name: A non-dependant 19-year-old's pay is not the claimant's income under any legacy means test diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py index 83af2fcb0f..fcfb8f56a2 100644 --- a/policyengine_uk/tests/test_means_test_income_properties.py +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -1,10 +1,10 @@ -"""Means tests count the claimant's and partner's income, nobody else's. +"""A means test never counts the income of someone outside its family. Invariant: for any benefit unit, giving income to a member who is neither the -claimant nor the partner (a child, a young person, or another adult in the -benefit unit) never changes a means test's income (UC Regs 2013 reg 22; HB -Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 s.7; CTR (Prescribed -Requirements) (England) Regs 2012 Sch 1 para 11; SPCA 2002 s.5). +claimant, the partner, nor the programme's own child or young person (for +example an 18- or 19-year-old presumed the claimant's child but not in +education, or a third adult) never changes a means test's income. The UC, HB, +IS, tax credit, CTR and Pension Credit income totals are covered. Each example builds families twice in one simulation: once with the other member's income and once without, in separate households and benefit units. @@ -27,25 +27,31 @@ "pension_credit_income", ] EDUCATIONS = ["NOT_IN_EDUCATION", "UPPER_SECONDARY", "TERTIARY"] +FAMILY_FLAGS = [ + "is_claimant_or_partner", + "is_child_or_qualifying_young_person_for_universal_credit", + "is_child_or_young_person_for_legacy_benefits", + "is_child_or_qualifying_young_person_for_child_tax_credit", +] money = st.floats(0, 50_000, allow_nan=False, allow_infinity=False) @st.composite def families(draw): # A head, an optional partner, and one member who by construction is - # neither claimant nor partner: a child under 16, a 16-19-year-old at - # least 16 years younger than the head (presumed the head's child), or a - # third adult no older than the partner (the partner, earlier in member - # order, wins any age tie). - kind = draw(st.sampled_from(["child", "presumed_child", "third_adult"])) - if kind == "child": - other_age = draw(st.integers(0, 15)) - head_age = draw(st.integers(25, 85)) - elif kind == "presumed_child": + # outside every programme's family: a 16-19-year-old at least 16 years + # younger than the head (presumed the head's child) who is not in + # non-advanced education, so no programme's young person; or a third adult + # no older than the partner (the partner, earlier in member order, wins any + # age tie). + kind = draw(st.sampled_from(["presumed_child", "third_adult"])) + if kind == "presumed_child": other_age = draw(st.integers(16, 19)) head_age = draw(st.integers(other_age + 16, 85)) + education = draw(st.sampled_from(["NOT_IN_EDUCATION", "TERTIARY"])) else: head_age = draw(st.integers(25, 85)) + education = draw(st.sampled_from(EDUCATIONS)) family = [ { "age": head_age, @@ -61,7 +67,7 @@ def families(draw): family.append( { "age": other_age, - "current_education": draw(st.sampled_from(EDUCATIONS)), + "current_education": education, "employment_income": draw(st.floats(1, 50_000)), "private_pension_income": draw(money), "savings_interest_income": draw(money), @@ -104,12 +110,13 @@ def test_income_of_non_claimants_never_counts(units): for family in units ] sim = Simulation(situation=situation(units + without)) - claimant = sim.calculate("is_claimant_or_partner", YEAR) offsets = np.cumsum([0] + [len(f) for f in units]) n = len(units) - for i in range(n): - # The generator's construction, checked against the model. - assert not claimant[offsets[i + 1] - 1], units[i] + for variable in FAMILY_FLAGS: + flags = sim.calculate(variable, YEAR) + for i in range(n): + # The generator's construction, checked against the model. + assert not flags[offsets[i + 1] - 1], (variable, units[i]) for variable in INCOME_TESTS: values = sim.calculate(variable, YEAR) for i in range(n): diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 211c3828e9..853726105f 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -1,22 +1,21 @@ """Benefit-unit aggregation helpers for means tests.""" -def add_for_claimant_and_partner(benunit, period, variables): - """Sum variables over the benefit unit's claimant and partner. +def add_for_members(benunit, period, variables, members): + """Sum variables over the benefit-unit members a means test counts. - Means tests assess the income of the claimant and any partner, not of - children, young persons or other members of the benefit unit (UC Regs - 2013 reg 22; HB Regs 2006 reg 25; IS Regs 1987 reg 23; TCA 2002 s.7; - CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11). - Person-level variables are summed over members flagged - ``is_claimant_or_partner``; benefit-unit variables are added as they are. + ``members`` is a person-level boolean array. Person-level variables are + summed over those members only; benefit-unit variables are added as they + are. Means tests pass the claimant and partner plus the programme's own + children or young persons, so a member who is none of these (for example + an 18-year-old who is neither a partner nor a qualifying young person) + does not count. """ person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) total = 0 for name in variables: if person.entity.get_variable(name).entity.is_person: - total = total + benunit.sum(person(name, period) * claimant_or_partner) + total = total + benunit.sum(person(name, period) * members) else: total = total + benunit(name, period) return total diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 39cb70d6df..f07a386d61 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -9,6 +9,14 @@ class housing_benefit_applicable_income(Variable): unit = GBP def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (HB Regs 2006 reg 25); dropping dependants' + # own income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_young_person_for_legacy_benefits", period + ) any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) BENUNIT_MEANS_TESTED_BENEFITS = [ "child_benefit", @@ -34,31 +42,26 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - # Add personal benefits, credits and total benefits to income. Only - # the claimant's and partner's income counts; a child's or young - # person's does not (HB Regs 2006 reg 25). - benefits = add_for_claimant_and_partner( - benunit, period, BENUNIT_MEANS_TESTED_BENEFITS - ) - income = add_for_claimant_and_partner(benunit, period, INCOME_COMPONENTS) - personal_benefits = add_for_claimant_and_partner( - benunit, period, PERSONAL_BENEFITS + # Add personal benefits, credits and total benefits to income + benefits = add_for_members( + benunit, period, BENUNIT_MEANS_TESTED_BENEFITS, members ) - credits = add_for_claimant_and_partner(benunit, period, ["tax_credits"]) + income = add_for_members(benunit, period, INCOME_COMPONENTS, members) + personal_benefits = add_for_members(benunit, period, PERSONAL_BENEFITS, members) + credits = add_for_members(benunit, period, ["tax_credits"], members) increased_income = income + personal_benefits + credits + benefits if not bi.interactions.include_in_means_tests: # Basic income is already in personal benefits, deduct if needed - increased_income -= add_for_claimant_and_partner( - benunit, period, ["basic_income"] + increased_income -= add_for_members( + benunit, period, ["basic_income"], members ) # Reduce increased income by pension contributions and tax pension_contributions = ( - add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) - * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 ) TAX_COMPONENTS = ["income_tax", "national_insurance"] - tax = add_for_claimant_and_partner(benunit, period, TAX_COMPONENTS) + tax = add_for_members(benunit, period, TAX_COMPONENTS, members) increased_income_reduced_by_tax_and_pensions = ( increased_income - tax - pension_contributions ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 38d3a51377..17aa562d36 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -10,6 +10,14 @@ class income_support_applicable_income(Variable): reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8" def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (IS Regs 1987 reg 23); dropping dependants' + # own income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_young_person_for_legacy_benefits", period + ) IS = parameters(period).gov.dwp.income_support INCOME_COMPONENTS = [ "employment_income", @@ -20,21 +28,15 @@ def formula(benunit, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: INCOME_COMPONENTS.append("basic_income") - # The claimant's and partner's income only (IS Regs 1987 reg 23). - income = add_for_claimant_and_partner(benunit, period, INCOME_COMPONENTS) - tax = add_for_claimant_and_partner( - benunit, - period, - ["income_tax", "national_insurance"], - ) - income += add_for_claimant_and_partner( - benunit, period, ["social_security_income"] + income = add_for_members(benunit, period, INCOME_COMPONENTS, members) + tax = add_for_members( + benunit, period, ["income_tax", "national_insurance"], members ) + income += add_for_members(benunit, period, ["social_security_income"], members) income += benunit("income_support_tariff_income", period) income -= tax income -= ( - add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) - * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 ) # Schedule 8 paras 5, 6 and 10 use mutually exclusive claimant types. mt = IS.means_test diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 27c3a39535..1c698c1921 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -12,6 +12,14 @@ class tax_credits_applicable_income(Variable): ) def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (TCA 2002 s.7); dropping dependants' own + # income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_qualifying_young_person_for_child_tax_credit", period + ) TC = parameters(period).gov.dwp.tax_credits STEP_1_COMPONENTS = [ "private_pension_income", @@ -19,8 +27,7 @@ def formula(benunit, period, parameters): "dividend_income", "property_income", ] - # The income of the claimant, or of both joint claimants (TCA 2002 s.7). - income = add_for_claimant_and_partner(benunit, period, STEP_1_COMPONENTS) + income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) income = max_(income - TC.means_test.non_earned_disregard, 0) STEP_2_COMPONENTS = [ "employment_income", @@ -31,9 +38,9 @@ def formula(benunit, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") - income += add_for_claimant_and_partner(benunit, period, STEP_2_COMPONENTS) + income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] on_exempt_benefits = ( - add_for_claimant_and_partner(benunit, period, EXEMPT_BENEFITS) > 0 + add_for_members(benunit, period, EXEMPT_BENEFITS, members) > 0 ) return income * ~on_exempt_benefits diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 0a55cd4229..6e496b8681 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -11,15 +11,21 @@ class uc_earned_income(Variable): reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/22" def formula(benunit, period, parameters): - # Only the claimant's (or joint claimants') earned income counts - # (UC Regs 2013 reg 22(1)(b)), with their own tax and pension - # contributions deducted. - personal_gross_earned_income = add_for_claimant_and_partner( - benunit, period, ["uc_mif_capped_earned_income"] + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (UC Regs 2013 reg 22); dropping dependants' + # own income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_qualifying_young_person_for_universal_credit", period ) - disregards = add_for_claimant_and_partner( + personal_gross_earned_income = add_for_members( + benunit, period, ["uc_mif_capped_earned_income"], members + ) + disregards = add_for_members( benunit, period, ["uc_work_allowance", "tax", "pension_contributions"], + members, ) return max_(0, personal_gross_earned_income - disregards) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py index eb2aedd5a3..82b6ec328b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py @@ -9,13 +9,17 @@ class uc_unearned_income(Variable): unit = GBP def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (UC Regs 2013 reg 22); dropping dependants' + # own income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) p = parameters(period).gov.dwp.universal_credit.means_test household = benunit.household - # Only the claimant's (or joint claimants') unearned income counts - # (UC Regs 2013 reg 22(1)(a)). - total = add_for_claimant_and_partner( - benunit, period, p.income_definitions.unearned - ) + total = add_for_members(benunit, period, p.income_definitions.unearned, members) tariff_income_applies = benunit("uc_tariff_income", period) > 0 reported_capital = benunit("uc_reported_capital", period) has_reported_capital = reported_capital >= 0 @@ -24,10 +28,10 @@ def formula(benunit, period, parameters): ) + household("non_residential_property_value", period) capital_derived_income = ( ((household("savings", period) > 0) | has_reported_capital) - * add_for_claimant_and_partner(benunit, period, ["savings_interest_income"]) + * add_for_members(benunit, period, ["savings_interest_income"], members) + ((household("corporate_wealth", period) > 0) | has_reported_capital) - * add_for_claimant_and_partner(benunit, period, ["dividend_income"]) + * add_for_members(benunit, period, ["dividend_income"], members) + ((property_capital > 0) | has_reported_capital) - * add_for_claimant_and_partner(benunit, period, ["property_income"]) + * add_for_members(benunit, period, ["property_income"], members) ) return total - tariff_income_applies * capital_derived_income diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 64b8a53f8b..444ebecc09 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -9,6 +9,15 @@ class council_tax_reduction_applicable_income(Variable): unit = GBP def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (CTR (Prescribed Requirements) (England) + # Regs 2012 Sch 1 para 11); dropping dependants' own income is a follow-up. + # Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_young_person_for_legacy_benefits", period + ) benunit_means_tested_benefits = [ "child_benefit", "income_support", @@ -33,28 +42,25 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - # The applicant's and partner's income only (CTR (Prescribed - # Requirements) (England) Regs 2012 Sch 1 para 11). - benefits = add_for_claimant_and_partner( - benunit, period, benunit_means_tested_benefits + benefits = add_for_members( + benunit, period, benunit_means_tested_benefits, members ) - income = add_for_claimant_and_partner(benunit, period, income_components) - personal_benefit_income = add_for_claimant_and_partner( - benunit, period, personal_benefits + income = add_for_members(benunit, period, income_components, members) + personal_benefit_income = add_for_members( + benunit, period, personal_benefits, members ) - credits = add_for_claimant_and_partner(benunit, period, ["tax_credits"]) + credits = add_for_members(benunit, period, ["tax_credits"], members) increased_income = income + personal_benefit_income + credits + benefits if not bi.interactions.include_in_means_tests: - increased_income -= add_for_claimant_and_partner( - benunit, period, ["basic_income"] + increased_income -= add_for_members( + benunit, period, ["basic_income"], members ) pension_contributions = ( - add_for_claimant_and_partner(benunit, period, ["pension_contributions"]) - * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 ) - tax = add_for_claimant_and_partner( - benunit, period, ["income_tax", "national_insurance"] + tax = add_for_members( + benunit, period, ["income_tax", "national_insurance"], members ) return max_(0, increased_income - tax - pension_contributions) From f9814593b56b425999cf3c66c4c339077e035dd6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:04:48 -0400 Subject: [PATCH 21/24] Drop non-members' hours from the HB worker disregard; take the eldest of several heads The Housing Benefit worker disregard (Sch 4 para 17) summed every member's weekly hours, so a non-member working 35 hours earned the claimant the disregard. It now uses the same members as the means-test income. With more than one member flagged as head (malformed input), the eldest adult head is the claimant, so a benefit unit never has more than two claimants. Co-Authored-By: Claude Opus 5.5 --- .../generic-child-adult-flags.fixed.md | 2 +- .../means-test-income-members.fixed.md | 2 +- .../gov/dwp/means_test_income_members.yaml | 41 +++++++++++++++++++ .../demographic/benefit_unit_structure.yaml | 24 +++++++++++ ..._child_and_adult_definitions_properties.py | 3 +- ...ing_benefit_applicable_income_disregard.py | 9 +++- .../demographic/is_claimant_or_partner.py | 11 +++-- 7 files changed, 85 insertions(+), 7 deletions(-) diff --git a/changelog.d/generic-child-adult-flags.fixed.md b/changelog.d/generic-child-adult-flags.fixed.md index bff79738d9..0949c5f232 100644 --- a/changelog.d/generic-child-adult-flags.fixed.md +++ b/changelog.d/generic-child-adult-flags.fixed.md @@ -1,4 +1,4 @@ - Fixed benefit-unit structure: a lone parent living with an 18- or 19-year-old still at school is no longer treated as a (married) couple, a couple with such a dependant is no longer treated as single or a lone parent, and a 16- or 17-year-old heading their own benefit unit is a single claimant rather than a couple with themselves as the child. Lone parent, single claimant and couple are now mutually exclusive. -- A benefit unit now has at most two claimants or partners: the head (or the eldest adult if the head is not an adult) and one partner (a member flagged as a parent if there is one, otherwise the eldest other adult). When the head is not flagged as a parent but two other members are, those two are the couple. Without relationship inputs, a member under 20 and at least 16 years younger than the head is presumed to be the head's child rather than their partner, so a lone parent with an 18-year-old at university stays single and a couple with one stays a couple of two. Datasets and users can supply `is_claimant_or_partner` directly. +- A benefit unit now has at most two claimants or partners: the head (the eldest adult head if more than one is given, or the eldest adult if the head is not an adult) and one partner (a member flagged as a parent if there is one, otherwise the eldest other adult). When the head is not flagged as a parent but two other members are, those two are the couple. Without relationship inputs, a member under 20 and at least 16 years younger than the head is presumed to be the head's child rather than their partner, so a lone parent with an 18-year-old at university stays single and a couple with one stays a couple of two. Datasets and users can supply `is_claimant_or_partner` directly. - Fixed Marriage Allowance and the CPS marriage reforms to transfer allowance and split income only between spouses or civil partners, never from a child in the benefit unit; the Tax-Free Childcare work condition to test the applicant and partner only; the WTC lone parent, couple, disability and childcare elements and eligibility routes to use the tax credit claimant and child definitions; and the DfE schemes (Care to Learn, Childcare Grant, Parents' Learning Allowance, extended childcare income test) to use each scheme's own definition of a child or of the parent and partner. - The HBAI equivalence scale now weights HBAI adults and dependent children, which changes factors only in households with no one aged 18 or over. diff --git a/changelog.d/means-test-income-members.fixed.md b/changelog.d/means-test-income-members.fixed.md index 783a52259e..9471d1b80c 100644 --- a/changelog.d/means-test-income-members.fixed.md +++ b/changelog.d/means-test-income-members.fixed.md @@ -1 +1 @@ -- Universal Credit, Housing Benefit, Income Support, tax credit and Council Tax Reduction means tests now count only the income of the claimant, any partner and the programme's own children or young persons, the members the model already counted. The income of any other member of the benefit unit no longer counts: for example an 18- or 19-year-old who is presumed to be the claimant's child but is neither a partner nor a qualifying young person. The shared helper is `add_for_members`. The regulations count only the claimant's and partner's income (UC Regulations 2013 reg 22; HB Regulations 2006 reg 25; IS Regulations 1987 reg 23; Tax Credits Act 2002 s.7; Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch 1 para 11). Dropping dependants' own income as well is left to a follow-up, because some survey records give children adult-level incomes. +- Universal Credit, Housing Benefit, Income Support, tax credit and Council Tax Reduction means tests now count only the income of the claimant, any partner and the programme's own children or young persons, the members the model already counted. The income of any other member of the benefit unit no longer counts: for example an 18- or 19-year-old who is presumed to be the claimant's child but is neither a partner nor a qualifying young person. The same applies to the hours behind the Housing Benefit worker disregard. The shared helper is `add_for_members`. The regulations count only the claimant's and partner's income (UC Regulations 2013 reg 22; HB Regulations 2006 reg 25; IS Regulations 1987 reg 23; Tax Credits Act 2002 s.7; Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch 1 para 11). Dropping dependants' own income as well is left to a follow-up, because some survey records give children adult-level incomes. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml index 3b15d3be88..8c05d0d82f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml @@ -119,3 +119,44 @@ tax_credits_applicable_income: 10_000 council_tax_reduction_applicable_income: 10_000 uc_earned_income: 10_000 + +- name: A non-member's hours do not earn the claimant the HB worker disregard + # HB Sch 4 para 17 tests the claimant's or partner's work. The 19-year-old + # (not in education, presumed the claimant's child, not a young person) + # works 35 hours; only the single-claimant disregard applies, as for the + # claimant alone. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + young_adult: + age: 19 + weekly_hours: 35 + employment_income: 15_000 + benunits: + benunit: + members: [claimant, young_adult] + households: + household: + members: [claimant, young_adult] + output: + is_claimant_or_partner: [true, false] + housing_benefit_applicable_income_disregard: 344.58 + +- name: The same claimant alone gets the same HB disregard + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + housing_benefit_applicable_income_disregard: 344.58 diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml index ececb79ead..ce6962b0a0 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/benefit_unit_structure.yaml @@ -347,3 +347,27 @@ members: [head, partner] output: is_couple: true + +- name: With two explicit heads, the eldest adult head is the claimant + # Malformed input: two members flagged as head. The eldest is the claimant, + # the other adult head becomes the partner, and the 24-year-old is neither. + period: 2025 + absolute_error_margin: 0 + input: + people: + head_1: + age: 45 + is_benunit_head: true + head_2: + age: 43 + is_benunit_head: true + other: + age: 24 + benunits: + benunit: + members: [head_1, head_2, other] + households: + household: + members: [head_1, head_2, other] + output: + is_claimant_or_partner: [true, true, false] diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index b6a5944837..2bb2f7413f 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -229,7 +229,8 @@ def claimants_or_partners(family): adults = [i for i in range(n) if adult[i]] if not adults: return [False] * n - claimant = heads[0] if heads else max(adults, key=lambda i: (ages[i], -i)) + pool = heads or adults + claimant = max(pool, key=lambda i: (ages[i], -i)) parent = [adult[i] and family[i]["is_parent"] for i in range(n)] other_parents = [i for i in range(n) if parent[i] and i != claimant] if len(other_parents) >= 2 and not parent[claimant]: 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 e5381c7c5a..f2366fcd62 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 @@ -12,7 +12,14 @@ class housing_benefit_applicable_income_disregard(Variable): def formula(benunit, period, parameters): WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard - hours = add(benunit, period, ["weekly_hours"]) + # Hours of the claimant and partner and, as the model did before, the + # legacy children or young persons; anyone else in the benefit unit + # does not count (Sch 4 para 17 tests the claimant's or partner's work). + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_young_person_for_legacy_benefits", period + ) + hours = add_for_members(benunit, period, ["weekly_hours"], members) # The claimant categories are mutually exclusive (Sch 4 paras 4, 7, 10). # Worker disregard is additional. single = benunit("is_single_person", period) diff --git a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py index 264f3b43ed..f41fbc3db5 100644 --- a/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py +++ b/policyengine_uk/variables/household/demographic/is_claimant_or_partner.py @@ -16,8 +16,8 @@ class is_claimant_or_partner(Variable): adult or a couple plus dependent children. - Only HBAI adults (`is_hbai_adult`) can be a claimant or partner. - - The claimant is the benefit-unit head, or the eldest adult if the head - is not an adult. + - The claimant is the benefit-unit head (the eldest adult head if more + than one is given), or the eldest adult if the head is not an adult. - The partner is one other member: the eldest other member flagged as a parent (`is_parent`) if there is one, otherwise the eldest other adult who is not presumed to be the claimant's child. A member under 20 and at @@ -50,7 +50,12 @@ def formula(person, period, parameters): eldest_adult = adult & ( person.get_rank(person.benunit, -age, condition=adult) == 0 ) - claimant = where(head_is_adult, is_head & adult, eldest_adult) + # With more than one head (malformed input), the eldest adult head. + adult_head = is_head & adult + eldest_adult_head = adult_head & ( + person.get_rank(person.benunit, -age, condition=adult_head) == 0 + ) + claimant = where(head_is_adult, eldest_adult_head, eldest_adult) claimant_age = person.benunit.max(where(claimant, age, -np.inf)) identified_parent = adult & person("is_parent", period) other_parent = identified_parent & ~claimant From 143cc3c4e6d02f9a1137ac0a31861889bb4b27cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 18:20:08 -0400 Subject: [PATCH 22/24] Expect a claimant's own Carer Support Payment not to bar their severe disability addition Commit 9b135036 stopped a person's own carer benefit barring their Pension Credit severe disability addition (SPC Regs 2002 Sch I para 1(1)(a)(iii): only a carer benefit paid for caring for them bars it). The Carer Support Payment test still expected the old any-carer-in-the-unit bar. Flip it, and add couple cases: both partners qualifying with a carer benefit paid for one gives the single amount (reg 6(5)); one partner qualifying and the other not blind gives nothing (para 1(1)(c)). Co-Authored-By: Claude Opus 5.5 --- .../tests/test_carer_support_payment.py | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_carer_support_payment.py b/policyengine_uk/tests/test_carer_support_payment.py index b812935aa5..c8594b7581 100644 --- a/policyengine_uk/tests/test_carer_support_payment.py +++ b/policyengine_uk/tests/test_carer_support_payment.py @@ -1,3 +1,5 @@ +import pytest + from policyengine_uk import Simulation @@ -43,7 +45,10 @@ def test_scottish_carers_move_to_csp_in_2025(): assert sim.calculate("carer_support_payment", YEAR_2025)[0] > 0 -def test_csp_counts_for_pension_credit_carer_additions_and_blocks_severe_disability(): +def test_csp_counts_for_pension_credit_carer_additions(): + # A person's own carer benefit does not bar their severe disability + # addition: SPC Regs 2002 Sch I para 1(1)(a)(iii) bars it only where a + # carer benefit is paid to someone "in respect of caring for him". sim = Simulation( situation=_situation( YEAR_2025, @@ -51,17 +56,73 @@ def test_csp_counts_for_pension_credit_carer_additions_and_blocks_severe_disabil ) ) parameters = sim.tax_benefit_system.parameters(str(YEAR_2025)) + guarantee_credit = parameters.gov.dwp.pension_credit.guarantee_credit - expected_carer_addition = ( - float(parameters.gov.dwp.pension_credit.guarantee_credit.carer.addition) * 52 + expected_carer_addition = float(guarantee_credit.carer.addition) * 52 + expected_severe_disability_addition = ( + float(guarantee_credit.severe_disability.addition) * 52 ) assert sim.calculate("carer_minimum_guarantee_addition", YEAR_2025)[0] == ( expected_carer_addition ) - assert ( - sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[0] == 0 + assert sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[ + 0 + ] == pytest.approx(expected_severe_disability_addition) + + +def _couple_situation(year: int, claimant: dict, partner: dict): + return { + "people": { + "claimant": {"age": {year: 35}, **claimant}, + "partner": {"age": {year: 35}, **partner}, + }, + "benunits": {"benunit": {"members": ["claimant", "partner"]}}, + "households": { + "household": { + "members": ["claimant", "partner"], + "country": {year: "SCOTLAND"}, + } + }, + } + + +def test_partner_csp_for_caring_gives_couple_severe_disability_single_rate_or_nil(): + # Sch I para 1(1)(b): a couple qualifies only if both partners get a + # qualifying benefit; reg 6(5) then pays the single amount where a carer + # benefit is paid for one of them. Where only one partner qualifies and the + # other is not blind, para 1(1)(c) gives nothing. + both_qualify = _couple_situation( + YEAR_2025, + claimant={"attendance_allowance": {YEAR_2025: 1}}, + partner={ + "attendance_allowance": {YEAR_2025: 1}, + "care_hours": {YEAR_2025: 40}, + }, + ) + one_qualifies = _couple_situation( + YEAR_2025, + claimant={"attendance_allowance": {YEAR_2025: 1}}, + partner={"care_hours": {YEAR_2025: 40}}, + ) + sim = Simulation(situation=both_qualify) + parameters = sim.tax_benefit_system.parameters(str(YEAR_2025)) + single_amount = ( + float( + parameters.gov.dwp.pension_credit.guarantee_credit.severe_disability.addition + ) + * 52 ) + assert sim.calculate("carer_support_payment", YEAR_2025)[1] > 0 + assert sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[ + 0 + ] == pytest.approx(single_amount) + + sim = Simulation(situation=one_qualifies) + assert sim.calculate("carer_support_payment", YEAR_2025)[1] > 0 + assert sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[ + 0 + ] == pytest.approx(0) def test_csp_counts_for_uc_non_dep_exemption_and_housing_benefit_income(): From dd8e98f8e65c82506c78e6c5da2b31b9c0055a89 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:38:33 -0400 Subject: [PATCH 23/24] Expect no guarantee credit for families not eligible for Pension Credit in the CTR tests The CTR tests from #1909 asserted a calculated guarantee credit for a working-age non-dependant, a mixed-age couple and a Welsh working-age earner. This branch defines guarantee_credit only for Pension Credit-eligible benefit units, so those are 0; the CTR outcomes the tests pin are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_pension_credit.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index ce21f7d8a4..6e52f66d3d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -239,9 +239,10 @@ council_tax: 2_000 savings: 0 output: - # The son's guarantee_credit is calculated as 7,376, but he is not - # eligible for Pension Credit, so he is not in receipt of it. - guarantee_credit: [3_376, 7_376] + # The son is not eligible for Pension Credit, so he has no guarantee + # credit (guarantee_credit is defined only for Pension Credit-eligible + # benefit units) and is not in receipt of it. + guarantee_credit: [3_376, 0] in_receipt_of_guarantee_credit: [true, false] council_tax_reduction_applicable_income: [0, 5_000] # The national schemes' non-dependant deduction currently uses Housing @@ -277,7 +278,8 @@ council_tax: 2_000 savings: 20_000 output: - guarantee_credit: 8_849 + # Not eligible for Pension Credit, so no guarantee credit. + guarantee_credit: 0 is_pension_credit_eligible: false in_receipt_of_guarantee_credit: false council_tax_reduction: 0 @@ -332,7 +334,7 @@ council_tax_reduction_applicable_income: 0 council_tax_reduction: 2_000 -- name: Wales working-age earner with a calculated guarantee credit keeps the taper +- name: Wales working-age earner, not eligible for Pension Credit, keeps the taper period: 2026 absolute_error_margin: 0.01 input: @@ -354,7 +356,8 @@ council_tax: 2_000 savings: 0 output: - guarantee_credit: 2_376 + # Working age, so not eligible for Pension Credit: no guarantee credit. + guarantee_credit: 0 in_receipt_of_guarantee_credit: false council_tax_reduction_applicable_income: 10_000 council_tax_reduction: 993.72 From 207e225c6bf092cbc5a5b14569e39e70ae20012b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:21:15 -0400 Subject: [PATCH 24/24] Give main's UC-benefit and CTR property fixtures explicit claimants and partners The property tests merged from #1958 and #1909 build couples such as 42 and 18 or 67 and 18 without relationship inputs, which this branch's presumption reads as a parent and child. Set is_claimant_or_partner from each generated role (adults True, children False, a separate non-dependant True for their own benefit unit), as the FRS supplies it. Co-Authored-By: Claude Opus 5.5 --- ...ouncil_tax_reduction_pension_credit_properties.py | 12 ++++++++++-- .../tests/test_uc_unearned_benefits_properties.py | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 717ee595ce..e32f8363e7 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -175,7 +175,10 @@ def situation( names = [] for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" - person = {"age": {YEAR: age}} + # The generated adults are the claimant and partner (a mixed-age + # couple may pair 67 with 18); say so, so the claimant-or-partner + # presumption does not apply. It must be set for everyone. + person = {"age": {YEAR: age}, "is_claimant_or_partner": {YEAR: True}} if age >= 67: person["state_pension"] = { YEAR: max(0.0, unit["state_pension"] + state_pension_change) @@ -193,7 +196,10 @@ def situation( names.append(name) for k, child_age in enumerate(unit.get("child_ages", [])): name = f"c{i}_{k}" - people[name] = {"age": {YEAR: child_age}} + people[name] = { + "age": {YEAR: child_age}, + "is_claimant_or_partner": {YEAR: False}, + } names.append(name) claim_pc = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc benunit = { @@ -213,6 +219,8 @@ def situation( people[name] = { "age": {YEAR: 40}, "employment_income": {YEAR: unit["non_dependant_income"]}, + # The claimant of their own benefit unit. + "is_claimant_or_partner": {YEAR: True}, } benunits[f"n{i}"] = { "members": [name], diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index 4a2cba3ae6..1b04d59149 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -117,7 +117,11 @@ def situation(units, year, income_variable, bump=0.0, carer=False): names = [] for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" - person = {"age": {year: age}} + # The generated adults are the claimant and partner; say so, so the + # claimant-or-partner presumption (a much younger member is the + # head's child) does not apply. Setting it for anyone makes it an + # input for everyone, so children get False below. + person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -129,7 +133,7 @@ def situation(units, year, income_variable, bump=0.0, carer=False): names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = {