From dc02692b5f47da2f71b1c34c4a8afa6872dbb248 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:07:38 -0400 Subject: [PATCH 1/6] Apply the Pension Credit severe disability residence and carer conditions SPC Regs 2002 Sch I para 1(1)(a)(ii), (b)(ii) and (c)(iii) with para 2: the addition needs no other resident aged 18 or over, ignoring qualifying-benefit recipients, blind people and qualifying young persons. Para 1(1)(a)(iii), (b)(ii) and (c)(iv) with reg 6(5): a carer benefit blocks or halves the addition only when it is paid for caring for a qualifying claimant or partner, so a claimant's own Carer's Allowance no longer blocks it. Co-Authored-By: Claude Opus 5.5 --- .../other_resident_age_limit.yaml | 11 +++ ...resident_for_severe_disability_addition.py | 43 ++++++++++ ...disability_addition_residence_condition.py | 30 +++++++ ...isability_addition_qualifiers_cared_for.py | 49 ++++++++++++ ..._disability_addition_qualifying_benefit.py | 26 +++++++ ...e_disability_minimum_guarantee_addition.py | 78 +++++++++++-------- 6 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/severe_disability/other_resident_age_limit.yaml create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_counted_resident_for_severe_disability_addition.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/receives_severe_disability_addition_qualifying_benefit.py diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/severe_disability/other_resident_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/severe_disability/other_resident_age_limit.yaml new file mode 100644 index 0000000000..10217f7c64 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/severe_disability/other_resident_age_limit.yaml @@ -0,0 +1,11 @@ +description: Age from which another person normally residing with the claimant stops the Pension Credit severe disability addition, unless the person is one whose presence is ignored. +values: + 2002-01-01: 18 +metadata: + unit: year + label: Pension Credit severe disability other resident age limit + reference: + - title: The State Pension Credit Regulations 2002 sch. I para. 1(1)(a)(ii), (b)(ii) and (c)(iii) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 sch. 3 para. 6(2)(a)(ii) and (b)(iii) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_counted_resident_for_severe_disability_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_counted_resident_for_severe_disability_addition.py new file mode 100644 index 0000000000..6387fde7a8 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_counted_resident_for_severe_disability_addition.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class is_counted_resident_for_severe_disability_addition(Variable): + value_type = bool + entity = Person + label = ( + "Counts as another resident for the Pension Credit severe disability addition" + ) + documentation = ( + "Aged 18 or over and not a person whose presence is ignored under " + "State Pension Credit Regulations 2002 Sch I para 2. Modelled " + "exceptions: a recipient of a qualifying disability benefit (para " + "2(2)(a)), a person certified blind or severely sight impaired (para " + "2(2)(b)), and a qualifying young person within regulation 4A (para " + "2(2)(f); a child under 16 is below the age limit anyway). Not " + "modelled, because the data do not identify them: sight regained " + "within 28 weeks (2(2)(c)), a carer engaged by a charity and their " + "partner (2(2)(d)-(e)), a carer in their first 12 weeks in the " + "household (2(3)-(4)), commercial lodgers and landlords who are not " + "close relatives (2(5)), joint occupiers and their partners (2(6)-(7)), " + "and people who share only a bathroom, lavatory or communal area or " + "are separately liable to the landlord (para 3). Each unmodelled " + "exception ignores a person, so leaving it out can only withhold the " + "addition. Whether the person resides with a given claimant is decided " + "by meets_severe_disability_addition_residence_condition." + ) + definition_period = YEAR + 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/schedule/I/paragraph/3", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit + adult = person("age", period) >= p.severe_disability.other_resident_age_limit + ignored = ( + person("receives_severe_disability_addition_qualifying_benefit", period) + | person("is_blind", period) + | person("is_qualifying_young_person_for_pension_credit", period) + ) + return adult & ~ignored diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py new file mode 100644 index 0000000000..9742f92e5f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class meets_severe_disability_addition_residence_condition(Variable): + value_type = bool + entity = BenUnit + label = "Meets the Pension Credit severe disability addition residence condition" + documentation = ( + "No person aged 18 or over, other than one whose presence is ignored, " + "normally resides with the claimant (and partner). Everyone else in the " + "household counts, including a member of the benefit unit who is " + "neither the claimant, the partner nor a qualifying young person. " + "Housing Benefit and Council Tax Reduction for pension-age claimants " + "apply the same test to non-dependants (HB(SPC) Regs 2006 Sch 3 para " + "6(2)(a)(ii), (b)(iii) and (6))." + ) + definition_period = YEAR + 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/2006/214/schedule/3/paragraph/6", + ) + + def formula(benunit, period, parameters): + person = benunit.members + counted = person("is_counted_resident_for_severe_disability_addition", period) + claimant_or_partner = person("is_claimant_or_partner", period) + counted_in_household = benunit.max(person.household.sum(counted)) + counted_claimants_or_partners = benunit.sum(counted & claimant_or_partner) + return counted_in_household == counted_claimants_or_partners diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py new file mode 100644 index 0000000000..b680884397 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py @@ -0,0 +1,49 @@ +from policyengine_uk.model_api import * + + +class num_severe_disability_addition_qualifiers_cared_for(Variable): + value_type = int + entity = BenUnit + label = ( + "Qualifying claimants or partners with a carer benefit paid for caring for them" + ) + documentation = ( + "How many of the claimant and partner who receive a qualifying " + "disability benefit have someone entitled to and in receipt of " + "Carer's Allowance or Carer Support Payment in respect of caring for " + "them. The data do not say whom a carer cares for, so: a claimant's or " + "partner's own carer benefit is for their partner if the partner " + "qualifies, and otherwise for someone else (no one is their own " + "carer, and a carer benefit needs the person cared for to receive a " + "qualifying disability benefit); a carer benefit received by anyone " + "else in the household is for a qualifying claimant or partner, each " + "for a different one. Carers outside the household, and Universal " + "Credit awards that include the carer element, are not counted. " + "Reading Universal Credit here would be circular, because the " + "Universal Credit non-dependant deduction exemption reads Pension " + "Credit; a Universal Credit carer in the household already stops the " + "addition through the residence condition unless their presence is " + "ignored." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", + ) + + def formula(benunit, period, parameters): + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifies = claimant_or_partner & person( + "receives_severe_disability_addition_qualifying_benefit", period + ) + carer = person("receives_carer_benefit", period) + qualifying = benunit.sum(qualifies) + partner_qualifies = claimant_or_partner & ( + benunit.project(qualifying) - qualifies > 0 + ) + partner_carers = benunit.sum(claimant_or_partner & carer & partner_qualifies) + household_carers = benunit.max(person.household.sum(carer)) + other_carers = household_carers - benunit.sum(claimant_or_partner & carer) + return min_(qualifying, partner_carers + other_carers) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/receives_severe_disability_addition_qualifying_benefit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/receives_severe_disability_addition_qualifying_benefit.py new file mode 100644 index 0000000000..bbd168150c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/receives_severe_disability_addition_qualifying_benefit.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class receives_severe_disability_addition_qualifying_benefit(Variable): + value_type = bool + entity = Person + label = "Receives a benefit qualifying for the Pension Credit severe disability addition" + documentation = ( + "Receives Attendance Allowance, the care component of Disability " + "Living Allowance at the middle or highest rate, the daily living " + "component of Personal Independence Payment, or Armed Forces " + "Independence Payment. The same list qualifies a claimant or partner " + "and makes another resident's presence ignored. The Scottish " + "equivalents (Pension Age Disability Payment, Adult Disability " + "Payment, Scottish Adult DLA) are not modelled separately." + ) + definition_period = YEAR + 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/2006/214/schedule/3/paragraph/6", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit + return add(person, period, p.severe_disability.relevant_benefits) > 0 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 212c027744..0c0cd67901 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 @@ -3,6 +3,30 @@ class severe_disability_minimum_guarantee_addition(Variable): label = "Severe disability-related increase" + documentation = ( + "The additional amount for a severely disabled claimant (State Pension " + "Credit Regulations 2002 reg 6(4)-(5) and Sch I paras 1-3). A single " + "claimant qualifies if they receive a qualifying disability benefit, " + "no other resident aged 18 or over counts, and no carer benefit is " + "paid for caring for them (para 1(1)(a)). A couple qualify if both " + "partners receive a qualifying benefit, no other resident counts, and " + "a carer benefit is paid for at most one of them (para 1(1)(b)); or, " + "failing that, if one partner receives a qualifying benefit, the other " + "is certified blind or severely sight impaired, no other resident " + "counts, and no carer benefit is paid for the first (para 1(1)(c)). " + "The amount is two rates for a couple under (b) with no carer benefit " + "paid for either partner, and one rate otherwise (reg 6(5)). Pension-age " + "Housing Benefit and Council Tax Reduction read this variable for " + "their severe disability premium (HB(SPC) Regs 2006 Sch 3 paras 6 and " + "12(1)). Not modelled: the hospital-patient deeming (para 1(2)(b)-(bd)) " + "and backdating (para 1(2)(a), (c)); sight regained within 28 weeks " + "(para 1(3)); polygamous marriages; and the unmodelled para 2 and 3 " + "exceptions and carer attribution described on " + "is_counted_resident_for_severe_disability_addition and " + "num_severe_disability_addition_qualifiers_cared_for. Blindness " + "(is_blind) is not in the survey data, so in microsimulation the " + "blind-partner route never applies and a blind resident is counted." + ) entity = BenUnit definition_period = YEAR value_type = float @@ -10,49 +34,37 @@ class severe_disability_minimum_guarantee_addition(Variable): 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/schedule/I/paragraph/3", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", ) def formula(benunit, period, parameters): - # Count qualifying claimants/partners; children and qualifying young - # people are ignored under Sch I para 2(2)(f). - # 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 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 - qualifier = claimant_or_partner & qualifies - qualifying = benunit.sum(qualifier) + qualifies = person( + "receives_severe_disability_addition_qualifying_benefit", period + ) + qualifying = benunit.sum(claimant_or_partner & qualifies) other_partner_blind = benunit.any( claimant_or_partner & ~qualifies & person("is_blind", period) ) - 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 & (carers_for_qualifier == 0), - ), - (qualifying >= 1) & (carers_for_qualifier == 0), + cared_for = benunit( + "num_severe_disability_addition_qualifiers_cared_for", period + ) + # Para 1(1)(b) with reg 6(5): two rates, less one for each partner a + # carer benefit is paid for (none if paid for both). Para 1(1)(c): one + # rate. Para 1(1)(a): one rate. + couple_rates = where( + qualifying >= 2, + 2 - cared_for, + (qualifying == 1) & other_partner_blind & (cared_for == 0), + ) + single_rates = (qualifying >= 1) & (cared_for == 0) + rates = where(benunit("is_couple", period), couple_rates, single_rates) + resides_alone = benunit( + "meets_severe_disability_addition_residence_condition", period ) - return amounts * severe_disability.addition * WEEKS_IN_YEAR + return rates * resides_alone * severe_disability.addition * WEEKS_IN_YEAR From 8d80437e970c11e919243309598e0d0ae1bae539 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:12:53 -0400 Subject: [PATCH 2/6] Attribute each carer within their own benefit unit first; add residence and carer tests A carer benefit paid to a claimant, partner or dependant is for a qualifying claimant or partner of the same unit when there is one, so it no longer also counts against another benefit unit in the household. Update the Carer Support Payment test: SPC Sch I para 1(1)(a)(iii) bars only a carer benefit paid for caring for the claimant, so a claimant's own CSP does not block it. Co-Authored-By: Claude Opus 5.5 --- ...evere_disability_residence_and_carers.yaml | 454 ++++++++++++++++++ ...isability_addition_qualifiers_cared_for.py | 44 +- 2 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml new file mode 100644 index 0000000000..d85b47d438 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -0,0 +1,454 @@ +# State Pension Credit Regulations 2002 (SI 2002/1792) reg 6(4)-(5) and +# Sch I paras 1-2: the residence condition (para 1(1)(a)(ii), (b)(ii) and +# (c)(iii), with the persons ignored under para 2) and the carer conditions +# (para 1(1)(a)(iii), (b)(ii) and (c)(iv)). 2026-27 weekly rates: one rate +# 86.05 (4,474.60 a year), two rates 172.10 (8,949.20 a year). +# Carer benefits are attributed as documented on +# num_severe_disability_addition_qualifiers_cared_for. + +- name: A single claimant on Attendance Allowance who lives alone gets one rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + meets_severe_disability_addition_residence_condition: true + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: An adult son living in the household stops the addition + # Para 1(1)(a)(ii): no person aged 18 or over normally resides with the + # claimant, other than a para 2 person. The son is his own benefit unit. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + son: + age: 45 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + households: + household: + members: [claimant, son] + output: + is_counted_resident_for_severe_disability_addition: [false, true] + meets_severe_disability_addition_residence_condition: [false, true] + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: An adult son who receives the PIP daily living component is ignored + # Para 2(2)(a). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + son: + age: 45 + pip_dl_category: STANDARD + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + households: + household: + members: [claimant, son] + output: + is_counted_resident_for_severe_disability_addition: [false, false] + # The son also qualifies in his own right and lives with no counted adult. + severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] + +- name: A blind adult son is ignored + # Para 2(2)(b). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: LOWER + son: + age: 45 + is_blind: true + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + households: + household: + members: [claimant, son] + output: + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + +- name: A resident aged 17 is under the age limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + grandchild: + age: 17 + benunits: + claimant_unit: + members: [claimant] + grandchild_unit: + members: [grandchild] + households: + household: + members: [claimant, grandchild] + output: + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + +- name: An 18-year-old qualifying young person in the claimant's benefit unit is ignored + # Para 2(2)(f): a qualifying young person within reg 4A. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_parent: true + aa_category: HIGHER + young_person: + age: 18 + is_in_non_advanced_education: true + benunits: + benunit: + members: [claimant, young_person] + households: + household: + members: [claimant, young_person] + output: + is_qualifying_young_person_for_pension_credit: [false, true] + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: A 19-year-old in the benefit unit who is not a qualifying young person counts + # Neither claimant, partner nor qualifying young person, so they are a + # person aged 18 or over residing with the claimant. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + is_parent: true + aa_category: HIGHER + adult_child: + age: 19 + benunits: + benunit: + members: [claimant, adult_child] + households: + household: + members: [claimant, adult_child] + output: + is_claimant_or_partner: [true, false] + meets_severe_disability_addition_residence_condition: false + severe_disability_minimum_guarantee_addition: 0 + +- name: A couple who both qualify lose both rates to an adult lodger + # Para 1(1)(b)(ii); a commercial lodger who is not a close relative would be + # ignored under para 2(5), which the model cannot identify. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + partner: + age: 72 + aa_category: LOWER + lodger: + age: 30 + benunits: + couple: + members: [claimant, partner] + lodger_unit: + members: [lodger] + households: + household: + members: [claimant, partner, lodger] + output: + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: A single claimant's own Carer's Allowance does not stop the addition + # Para 1(1)(a)(iii) bars only a carer benefit paid in respect of caring for + # the claimant; no one is their own carer. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + receives_carer_benefit: true + num_severe_disability_addition_qualifiers_cared_for: 0 + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: Carer's Allowance paid to another household member stops a single claimant's addition + # The carer receives PIP daily living, so para 2(2)(a) ignores them for the + # residence condition; their Carer's Allowance is treated as caring for the + # claimant (para 1(1)(a)(iii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + daughter: + age: 50 + pip_dl_category: STANDARD + care_hours: 35 + carers_allowance_reported: 1 + benunits: + claimant_unit: + members: [claimant] + daughter_unit: + members: [daughter] + households: + household: + members: [claimant, daughter] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 0] + severe_disability_minimum_guarantee_addition: [0, 4_474.60] + +- name: A couple who both qualify get one rate when the claimant receives Carer's Allowance + # The claimant is treated as caring for the qualifying partner: a carer + # benefit for one only of the partners (para 1(1)(b)(ii)), so reg 6(5)(a). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + partner: + age: 72 + aa_category: HIGHER + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: 1 + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: A couple who both qualify get one rate when another household member receives Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + partner: + age: 72 + aa_category: HIGHER + daughter: + age: 50 + pip_dl_category: STANDARD + care_hours: 35 + carers_allowance_reported: 1 + benunits: + couple: + members: [claimant, partner] + daughter_unit: + members: [daughter] + households: + household: + members: [claimant, partner, daughter] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 0] + severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] + +- name: A couple get nothing when carer benefits are paid for both partners + # The partner cares for the claimant and the daughter for the partner: + # para 1(1)(b)(ii) fails, and so does (c)(iv). + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + partner: + age: 72 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + daughter: + age: 50 + pip_dl_category: STANDARD + care_hours: 35 + carers_allowance_reported: 1 + benunits: + couple: + members: [claimant, partner] + daughter_unit: + members: [daughter] + households: + household: + members: [claimant, partner, daughter] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [2, 0] + severe_disability_minimum_guarantee_addition: [0, 4_474.60] + +- name: A qualifying partner's own Carer's Allowance does not stop the blind-partner route + # Para 1(1)(c)(iv) bars a carer benefit for the qualifying partner. The + # qualifying partner's own allowance cannot be for the blind partner, who + # receives no qualifying benefit, so it is for someone else. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + partner: + age: 72 + is_blind: true + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: 0 + severe_disability_minimum_guarantee_addition: 4_474.60 + +- name: The blind-partner route also needs no other adult resident + # Para 1(1)(c)(iii). + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + partner: + age: 72 + is_blind: true + son: + age: 40 + benunits: + couple: + members: [claimant, partner] + son_unit: + members: [son] + households: + household: + members: [claimant, partner, son] + output: + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: Two pensioner benefit units sharing a household each count against the other + # Two sisters, each her own benefit unit; only one receives Attendance + # Allowance, so she is ignored for her sister but her sister counts for her. + period: 2026 + absolute_error_margin: 0 + input: + people: + sister_one: + age: 80 + aa_category: HIGHER + sister_two: + age: 78 + benunits: + unit_one: + members: [sister_one] + unit_two: + members: [sister_two] + households: + household: + members: [sister_one, sister_two] + output: + meets_severe_disability_addition_residence_condition: [false, true] + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: Residents in another household do not count + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + neighbour: + age: 45 + benunits: + claimant_unit: + members: [claimant] + neighbour_unit: + members: [neighbour] + households: + claimant_household: + members: [claimant] + neighbour_household: + members: [neighbour] + output: + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + +- name: The addition carries into the minimum guarantee + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + son: + age: 45 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + households: + household: + members: [claimant, son] + output: + # Standard minimum guarantee only: 238.00 x 52 = 12,376. + minimum_guarantee: [12_376, 12_376] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py index b680884397..9ff54ff8f4 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py @@ -11,19 +11,20 @@ class num_severe_disability_addition_qualifiers_cared_for(Variable): "How many of the claimant and partner who receive a qualifying " "disability benefit have someone entitled to and in receipt of " "Carer's Allowance or Carer Support Payment in respect of caring for " - "them. The data do not say whom a carer cares for, so: a claimant's or " - "partner's own carer benefit is for their partner if the partner " - "qualifies, and otherwise for someone else (no one is their own " - "carer, and a carer benefit needs the person cared for to receive a " - "qualifying disability benefit); a carer benefit received by anyone " - "else in the household is for a qualifying claimant or partner, each " - "for a different one. Carers outside the household, and Universal " - "Credit awards that include the carer element, are not counted. " - "Reading Universal Credit here would be circular, because the " - "Universal Credit non-dependant deduction exemption reads Pension " - "Credit; a Universal Credit carer in the household already stops the " - "addition through the residence condition unless their presence is " - "ignored." + "them. The data do not say whom a carer cares for, and a carer benefit " + "needs the person cared for to receive a qualifying disability " + "benefit, so each carer is attributed as follows. A claimant or partner " + "cares for their partner if the partner qualifies; any other member of " + "a benefit unit cares for a qualifying claimant or partner of that " + "unit if there is one. No one is their own carer. Every other carer " + "benefit in the household is treated as paid for a qualifying claimant " + "or partner of each other benefit unit there, each carer for a " + "different one. Carers outside the household, and Universal Credit " + "awards that include the carer element, are not counted. Reading " + "Universal Credit here would be circular, because the Universal " + "Credit non-dependant deduction exemption reads Pension Credit; a " + "Universal Credit carer in the household already stops the addition " + "through the residence condition unless their presence is ignored." ) definition_period = YEAR reference = ( @@ -40,10 +41,13 @@ def formula(benunit, period, parameters): ) carer = person("receives_carer_benefit", period) qualifying = benunit.sum(qualifies) - partner_qualifies = claimant_or_partner & ( - benunit.project(qualifying) - qualifies > 0 - ) - partner_carers = benunit.sum(claimant_or_partner & carer & partner_qualifies) - household_carers = benunit.max(person.household.sum(carer)) - other_carers = household_carers - benunit.sum(claimant_or_partner & carer) - return min_(qualifying, partner_carers + other_carers) + # Qualifying claimants or partners in the carer's own unit, other than + # the carer. + qualifiers_in_own_unit = benunit.project(qualifying) - qualifies + cares_within_unit = carer & (qualifiers_in_own_unit > 0) + cares_outside_unit = carer & ~cares_within_unit + within_unit = benunit.sum(cares_within_unit) + from_other_units = benunit.max( + person.household.sum(cares_outside_unit) + ) - benunit.sum(cares_outside_unit) + return min_(qualifying, within_unit + from_other_units) From e3ffdab3a895921d975a3dcf3a8f6100a3410941 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:32:34 -0400 Subject: [PATCH 3/6] Add Hypothesis properties with an independent reference, and a changelog entry test_severe_disability_addition_properties.py compares the addition with a reference written from the law text alone (differential), checks the rate bounds and the qualifying-benefit list, and checks that the residence condition, carer benefits and swapping partners behave as the law requires (metamorphic). Co-Authored-By: Claude Opus 5.5 --- .../pc-sda-statutory-conditions.fixed.md | 1 + .../severe_disability_addition_reference.py | 455 ++++++++++++++++++ ...t_severe_disability_addition_properties.py | 267 ++++++++++ 3 files changed, 723 insertions(+) create mode 100644 changelog.d/pc-sda-statutory-conditions.fixed.md create mode 100644 policyengine_uk/tests/severe_disability_addition_reference.py create mode 100644 policyengine_uk/tests/test_severe_disability_addition_properties.py diff --git a/changelog.d/pc-sda-statutory-conditions.fixed.md b/changelog.d/pc-sda-statutory-conditions.fixed.md new file mode 100644 index 0000000000..6946ce3bdf --- /dev/null +++ b/changelog.d/pc-sda-statutory-conditions.fixed.md @@ -0,0 +1 @@ +- Apply the State Pension Credit severe disability addition's residence and carer conditions (SPC Regs 2002 Sch I paras 1-2 and reg 6(5)): no other resident aged 18 or over, ignoring qualifying-benefit recipients, blind people and qualifying young persons; and a carer benefit reduces or removes the addition only when it is paid for caring for a qualifying claimant or partner, so a claimant's own Carer's Allowance no longer removes it. Pension-age Housing Benefit and Council Tax Reduction follow once their severe disability premium reads this variable. diff --git a/policyengine_uk/tests/severe_disability_addition_reference.py b/policyengine_uk/tests/severe_disability_addition_reference.py new file mode 100644 index 0000000000..951735cd3c --- /dev/null +++ b/policyengine_uk/tests/severe_disability_addition_reference.py @@ -0,0 +1,455 @@ +"""Independent reference: Pension Credit severe disability additional amount. + +Test oracle for test_severe_disability_addition_properties.py. It was written +from the law text and a written specification by a separate agent that did not +see the implementation under test; the only later edit is rule 3 (which unit's +claimant or partner a carer in another benefit unit is treated as caring for), +marked below. + +State Pension Credit Regulations 2002 (SI 2002/1792): + * regulation 6(4) and 6(5) (law/spc_reg6.txt) + * Schedule I, Part I, paragraph 1(1)(a)-(c) (law/spc_sch1_p1.txt) + * Schedule I, Part I, paragraph 2 (law/spc_sch1_p2.txt) + * Schedule I, Part I, paragraph 3 (law/spc_sch1_p3.txt) + +Written from the law text and the differential-test spec only. It does not +import or read the PolicyEngine implementation under test. + +``reference_rates(household)`` returns, for every benefit unit, the number of +single-rate severe disability additional amounts: 0, 1 (reg 6(5)(a), £86.05) +or 2 (reg 6(5)(b), £172.10). + +Input format:: + + household = { + "benunits": {bu_id: {"claimant_or_partner": [pid, ...], + "others": [pid, ...]}}, + "people": {pid: {"age": int, + "qualifying_benefit": bool, + "blind": bool, + "qualifying_young_person": bool, + "receives_carer_benefit": bool}}, + } + +A unit with two ``claimant_or_partner`` entries is a couple; with one, a single +claimant. ``others`` are unit members who are neither claimant nor partner. + +Modelling rules (from the spec; each is applied literally below): + +1. Qualifying benefit. A person satisfies para 1(1)(a)(i), (b)(i) and (c)(i) + iff ``qualifying_benefit``. +2. Residence. Para 1(1)(a)(ii), (b)(ii) and (c)(iii) fail if anyone in the + household other than this unit's claimant/partner is aged 18 or over and is + not a para 2 person. Modelled para 2 persons: qualifying benefit (2(2)(a)), + blind (2(2)(b)), qualifying young person (2(2)(f)). No other exceptions. +3. Carers. Nobody cares for themselves. A claimant/partner who receives a carer + benefit cares for their partner if the partner qualifies; otherwise they care + for someone outside the unit. Any other member of a unit who receives a carer + benefit cares for a qualifying claimant/partner of their own unit if it has + one. Every remaining carer in the household (one not caring within their own + unit) cares for a qualifying claimant/partner of THIS unit. Each such carer + cares for a different qualifying person who is not already cared for, until + every qualifying claimant/partner is cared for. +4. Heads. Para 1(1)(a) applies to singles; (b) and then (c) apply to couples. + (c) applies only where (b) does not, and (c)(iv) needs no carer for the + partner to whom (c)(i) applies. If both partners qualify, either may be the + (c)(i) person, and the other must be blind. Then reg 6(5): 2 if (b) is + satisfied and nobody cares for either partner; otherwise 1 if any of (a), + (b) or (c) is satisfied; otherwise 0. + +Not modelled (outside the spec): reg 6(2)-(3) prisoners and members of +religious orders; para 1(2) deemed receipt (backdated awards, payments in lieu, +hospital patients, carer benefit before first payment); para 1(3) 28-week run-on +for regained sight; para 2(2)(c), (d), (e), 2(3)-(7); para 3 (what counts as +"residing with"). Everyone in ``people`` counts as normally residing with every +benefit unit in the household. +""" + +from __future__ import annotations + +from itertools import combinations +from typing import Any, Hashable, Mapping, Sequence + +Person = Mapping[str, Any] +PersonId = Hashable + +# Sch I para 1(1)(a)(ii), (b)(ii), (c)(iii): "no person who has attained the age of 18". +# A person "has attained the age of 18" on their 18th birthday, so the test is age >= 18. +ADULT_AGE = 18 + + +# --------------------------------------------------------------------------- +# Rule 1: the qualifying disability benefit +# --------------------------------------------------------------------------- + + +def qualifies(person: Person) -> bool: + """Sch I para 1(1)(a)(i), (b)(i) and (c)(i): receipt of a qualifying benefit. + + Law, para 1(1)(a)(i): "he is in receipt of attendance allowance , pension age + disability payment , the care component of disability living allowance at the + highest or middle rate [...] the daily living component of personal + independence payment at the standard or enhanced rate [...] or armed forces + independence payment". + + (b)(i) asks that "both partners are in receipt of" the same list and (c)(i) + that "either the claimant or his partner is in receipt of" it. + + Spec rule 1: a person qualifies iff ``qualifying_benefit``. + """ + return bool(person["qualifying_benefit"]) + + +# --------------------------------------------------------------------------- +# Rule 2: the residence (non-dependant) condition +# --------------------------------------------------------------------------- + + +def is_paragraph_2_person(person: Person) -> bool: + """Sch I para 2: residents whose presence is ignored for the residence test. + + Law, para 2(1): "For the purposes of paragraph 1(1)(a)(ii), (b)(ii) and + (c)(iii), this paragraph applies to the persons specified in the following + sub-paragraphs." + + Modelled heads of para 2(2), "A person who--": + (a) "is in receipt of attendance allowance , pension age disability payment , + the care component of disability living allowance at the highest or middle + rate [...] or armed forces independence payment;" -> qualifying_benefit + (b) "is certified as severely sight impaired or blind by a consultant + ophthalmologist;" -> blind + (f) "is a person who is a qualifying young person within the meaning of + regulation 4A or child as defined in section 40 of the 2012 Act." + -> qualifying_young_person + (A section 40 "child" is under 16, so the age test already ignores + them.) + + Spec rule 2: no other exceptions. 2(2)(c)-(e) and 2(3)-(7) are not modelled. + """ + return bool( + person["qualifying_benefit"] # para 2(2)(a) + or person["blind"] # para 2(2)(b) + or person["qualifying_young_person"] # para 2(2)(f) + ) + + +def is_disqualifying_resident(person: Person) -> bool: + """A resident who breaks the residence condition. + + Law, para 1(1)(a)(ii): "no person who has attained the age of 18 is normally + residing with the claimant, nor is the claimant normally residing with such a + person, other than a person to whom paragraph 2 applies". + """ + return person["age"] >= ADULT_AGE and not is_paragraph_2_person(person) + + +def residence_condition_met( + people: Mapping[PersonId, Person], + claimant_or_partner: Sequence[PersonId], +) -> bool: + """Sch I para 1(1)(a)(ii) (single), (b)(ii) first limb and (c)(iii) (couple). + + Law, para 1(1)(a)(ii): "no person who has attained the age of 18 is normally + residing with the claimant, nor is the claimant normally residing with such a + person, other than a person to whom paragraph 2 applies". + + Law, para 1(1)(b)(ii) and (c)(iii): "no person who has attained the age of 18 + is normally residing with the partners, nor are the partners normally residing + with such a person, other than a person to whom paragraph 2 applies". + + Spec rule 2: everyone in the household except this unit's claimant/partner is + tested. That covers "others" in this unit and every member of any other unit. + A partner is never "residing with the partners" in this sense, so an adult + partner without a qualifying benefit does not break the condition. + """ + own = set(claimant_or_partner) + return not any( + is_disqualifying_resident(person) + for pid, person in people.items() + if pid not in own + ) + + +# --------------------------------------------------------------------------- +# Rule 3: who is cared for (carer's allowance, carer support payment, UC carer element) +# --------------------------------------------------------------------------- + + +def _cares_within_other_unit(people, units, pid, unit_id) -> bool: + """[Rule 3 as amended] Whether ``pid``, a member of a unit other than + ``unit_id``, cares for a qualifying claimant or partner of their own unit.""" + for other_id, other in units.items(): + if other_id == unit_id: + continue + members = list(other["claimant_or_partner"]) + list(other.get("others", [])) + if pid not in members: + continue + return any( + qualifies(people[q]) for q in other["claimant_or_partner"] if q != pid + ) + return False + + +def carer_assignments( + people: Mapping[PersonId, Person], + claimant_or_partner: Sequence[PersonId], + units: Mapping[Hashable, Any] = None, + unit_id: Hashable = None, +) -> list[frozenset]: + """Every allowed set of this unit's claimant/partners who are cared for. + + The carer conditions are para 1(1)(a)(iii), (b)(ii) second limb, (c)(iv) and + reg 6(5)(b). Each asks whether a person "is entitled to and in receipt of an + allowance under section 70 of the 1992 Act (carer's allowance) or carer support + payment , or has an award of universal credit which includes the carer + element, in respect of caring for" a named person. + + Spec rule 3: + * Nobody cares for themselves. + * A claimant/partner who receives a carer benefit cares for their partner if + the partner qualifies; otherwise they care for someone outside the unit. + * Every other household member (an "other" in this unit or anyone in another + unit) who receives a carer benefit cares for a qualifying claimant/partner + of THIS unit. Each cares for a different qualifying person who is not + already cared for, until every qualifying claimant/partner is cared for. + + Rule 3 does not say which partner an outside carer takes when both qualify and + neither is yet cared for. This function returns every allowed assignment, and + ``reference_rates`` asserts that the answer is the same for all of them. + """ + units = units or {} + unit = list(claimant_or_partner) + qualifying = [pid for pid in unit if qualifies(people[pid])] + + # Partner carers: A cares for B only when B qualifies. A claimant/partner is + # never their own carer, and in a single unit has no partner to care for. + cared_by_partner = set() + if len(unit) == 2: + first, second = unit + for carer, cared in ((first, second), (second, first)): + if people[carer]["receives_carer_benefit"] and qualifies(people[cared]): + cared_by_partner.add(cared) + + # Outside carers: every household member who is not this unit's claimant or + # partner and who is not caring within their own (other) unit. [Rule 3 as + # amended: a carer in another unit cares there if that unit has a + # qualifying claimant or partner other than the carer.] + own = set(unit) + outside_carers = sum( + 1 + for pid, person in people.items() + if pid not in own + and person["receives_carer_benefit"] + and not _cares_within_other_unit(people, units, pid, unit_id) + ) + + still_uncared = [pid for pid in qualifying if pid not in cared_by_partner] + newly_cared = min(outside_carers, len(still_uncared)) + return [ + frozenset(cared_by_partner) | frozenset(choice) + for choice in combinations(still_uncared, newly_cared) + ] + + +# --------------------------------------------------------------------------- +# Rule 4: the three heads of para 1(1) and reg 6(5) +# --------------------------------------------------------------------------- + + +def head_a_satisfied( + people: Mapping[PersonId, Person], + claimant: PersonId, + residence_ok: bool, + cared_for: frozenset, +) -> bool: + """Sch I para 1(1)(a): "in the case of a claimant who has no partner--" + + (i) "he is in receipt of attendance allowance , [...] or armed forces + independence payment; and" + (ii) "no person who has attained the age of 18 is normally residing with the + claimant, [...] other than a person to whom paragraph 2 applies; and" + (iii) "no person is entitled to and in receipt of an allowance under section 70 + of the 1992 Act (carer's allowance) or carer support payment , or has an + award of universal credit which includes the carer element, in respect of + caring for him;" + """ + return ( + qualifies(people[claimant]) # (a)(i) + and residence_ok # (a)(ii) + and claimant not in cared_for # (a)(iii) + ) + + +def head_b_satisfied( + people: Mapping[PersonId, Person], + partners: Sequence[PersonId], + residence_ok: bool, + cared_for: frozenset, +) -> bool: + """Sch I para 1(1)(b): "in the case of a claimant who has a partner--" + + (i) "both partners are in receipt of attendance allowance , [...] or armed + forces independence payment; and" + (ii) "no person who has attained the age of 18 is normally residing with the + partners, [...] other than a person to whom paragraph 2 applies; and + either a person is entitled to, and in receipt of, an allowance under + section 70 of the 1992 Act or carer support payment , or has an award of + universal credit which includes the carer element, in respect of caring + for one only of the partners or, as the case may be, no person is + entitled to, and in receipt of, such an allowance [...] in respect of + caring for either partner;" + + The carer limb of (b)(ii) holds when zero or one partner is cared for. It + fails only when both are. + """ + both_qualify = all(qualifies(people[pid]) for pid in partners) # (b)(i) + partners_cared_for = sum(1 for pid in partners if pid in cared_for) + carer_limb = partners_cared_for <= 1 # (b)(ii): "one only" or "either" (none) + return both_qualify and residence_ok and carer_limb + + +def head_c_satisfied( + people: Mapping[PersonId, Person], + partners: Sequence[PersonId], + residence_ok: bool, + cared_for: frozenset, +) -> bool: + """Sch I para 1(1)(c): "in the case of a claimant who has a partner and to whom + head (b) does not apply--" + + (i) "either the claimant or his partner is in receipt of attendance + allowance , [...] or armed forces independence payment; and" + (ii) "the other partner is certified as severely sight impaired or blind by a + consultant ophthalmologist; and" + (iii) "no person who has attained the age of 18 is normally residing with the + partners, [...] other than a person to whom paragraph 2 applies; and" + (iv) "no person is entitled to and in receipt of an allowance under section 70 + of the 1992 Act or carer support payment , or has an award of universal + credit which includes the carer element, in respect of caring for the + person to whom head (c) (i) above applies." + + The caller checks "to whom head (b) does not apply". Where both partners + qualify, either may be the (c)(i) person (spec rule 4), so (c) holds if any + ordered choice works. + """ + first, second = partners + for c_i_person, other_partner in ((first, second), (second, first)): + if ( + qualifies(people[c_i_person]) # (c)(i) + and people[other_partner]["blind"] # (c)(ii) + and residence_ok # (c)(iii) + and c_i_person not in cared_for # (c)(iv) + ): + return True + return False + + +def rates_for_unit( + people: Mapping[PersonId, Person], + claimant_or_partner: Sequence[PersonId], + cared_for: frozenset, +) -> int: + """Reg 6(4) and 6(5) for one benefit unit and one carer assignment. + + Law, reg 6(4): "Except in a case to which paragraph (3) applies, an amount + additional to that prescribed in paragraph (1) shall be applicable under + paragraph (5) if the claimant is treated as being a severely disabled person in + accordance with paragraph 1 of Part I of Schedule I." + + Law, reg 6(5): "The additional amount applicable is-- + (a) except where paragraph (b) applies, £86.05 per week if paragraph 1(1)(a), + (b) or (c) of Part I of Schedule I is satisfied; or + (b) £172.10 per week if paragraph 1(1)(b) of Part I of Schedule I is satisfied + otherwise than by virtue of paragraph 1(2)(b) of that Part and no one is + entitled to and in receipt of an allowance under section 70 of the 1992 + Act or carer support payment , or has an award of universal credit which + includes the carer element under regulation 29 of the Universal Credit + Regulations 2013, in respect of caring for either partner." + + The 1(2)(b) proviso (hospital patients deemed in receipt) is outside the model: + ``qualifying_benefit`` means actual receipt, so the proviso always holds. Reg + 6(3) (prisoners, religious orders) is not modelled either. + """ + residence_ok = residence_condition_met(people, claimant_or_partner) + + if len(claimant_or_partner) == 1: + (claimant,) = claimant_or_partner + return 1 if head_a_satisfied(people, claimant, residence_ok, cared_for) else 0 + + partners = list(claimant_or_partner) + b = head_b_satisfied(people, partners, residence_ok, cared_for) + # (c) applies only "to whom head (b) does not apply". + c = (not b) and head_c_satisfied(people, partners, residence_ok, cared_for) + nobody_cares_for_either = not any(pid in cared_for for pid in partners) + if b and nobody_cares_for_either: + return 2 # reg 6(5)(b) + if b or c: + return 1 # reg 6(5)(a) + return 0 + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +_PERSON_FIELDS = ( + "age", + "qualifying_benefit", + "blind", + "qualifying_young_person", + "receives_carer_benefit", +) + + +def _validate(household: Mapping[str, Any]) -> None: + people = household["people"] + seen: dict = {} + for bu_id, unit in household["benunits"].items(): + cp = list(unit["claimant_or_partner"]) + if len(cp) not in (1, 2): + raise ValueError( + f"benefit unit {bu_id!r} needs 1 or 2 claimant_or_partner, got {len(cp)}" + ) + for pid in cp + list(unit.get("others", [])): + if pid not in people: + raise ValueError(f"benefit unit {bu_id!r} names unknown person {pid!r}") + if pid in seen: + raise ValueError( + f"person {pid!r} appears in benefit units {seen[pid]!r} and {bu_id!r}" + ) + seen[pid] = bu_id + for pid, person in people.items(): + missing = [f for f in _PERSON_FIELDS if f not in person] + if missing: + raise ValueError(f"person {pid!r} is missing {missing}") + + +def reference_rates(household: Mapping[str, Any]) -> dict: + """Number of single-rate SDA amounts (0, 1 or 2) for every benefit unit. + + The household is everyone in ``household["people"]``. Each benefit unit is + tested against every other member of the household (spec rules 2 and 3). + """ + _validate(household) + people = household["people"] + result = {} + for bu_id, unit in household["benunits"].items(): + claimant_or_partner = list(unit["claimant_or_partner"]) + outcomes = { + rates_for_unit(people, claimant_or_partner, cared_for) + for cared_for in carer_assignments( + people, claimant_or_partner, household["benunits"], bu_id + ) + } + # Rule 3 leaves open which qualifying partner an outside carer takes. The + # law makes that choice immaterial: (b) depends only on how many partners + # are cared for; (c) can be reached with the residence condition met only + # when both partners are cared for, and then (c)(iv) fails for either choice. + # Fail loudly if that reasoning is ever wrong. + if len(outcomes) != 1: + raise AssertionError( + f"benefit unit {bu_id!r}: result depends on carer assignment: {outcomes}" + ) + result[bu_id] = outcomes.pop() + return result diff --git a/policyengine_uk/tests/test_severe_disability_addition_properties.py b/policyengine_uk/tests/test_severe_disability_addition_properties.py new file mode 100644 index 0000000000..bf84fa49ca --- /dev/null +++ b/policyengine_uk/tests/test_severe_disability_addition_properties.py @@ -0,0 +1,267 @@ +"""Property-based tests for the Pension Credit severe disability addition. + +State Pension Credit Regulations 2002 reg 6(4)-(5) and Sch I paras 1-2, as +encoded in severe_disability_minimum_guarantee_addition and the variables it +reads. Households are generated with a pension-age benefit unit (single or +couple, possibly with dependants) and up to two other benefit units, each +person drawn with a disability benefit (or none), blindness, a carer benefit +and, for 16- to 19-year-olds, non-advanced education. is_claimant_or_partner is +set from the generated roles, as the survey data supply it. + +Invariants, for every benefit unit in every generated household: + +1. Differential: the number of rates equals that of an independent reference + implementation written from the law text + (severe_disability_addition_reference.py). +2. Structural: the addition is 0, 1 or 2 times the weekly rate times 52, and + 2 only for a couple. The qualifying-benefit flag matches the Sch I para + 1(1)(a)(i) list for the drawn benefit categories. +3. Residence, metamorphic: adding another adult resident who is not a para 2 + person removes the addition for every other benefit unit; adding a resident + whose presence is ignored (a qualifying-benefit recipient, a blind person, + a qualifying young person or anyone under 18) who receives no carer benefit + leaves every existing unit's addition unchanged. +4. Carers, metamorphic: giving someone a carer benefit never increases any + unit's addition. +5. Symmetry: swapping the drawn attributes of a couple's two partners leaves + their addition unchanged. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from severe_disability_addition_reference import reference_rates + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS_IN_YEAR = 52 +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Drawn benefit category -> whether Sch I para 1(1)(a)(i) lists it. +BENEFITS = { + "none": ({}, False), + "aa_lower": ({"aa_category": "LOWER"}, True), + "aa_higher": ({"aa_category": "HIGHER"}, True), + "dla_care_lowest": ({"dla_sc_category": "LOWER"}, False), + "dla_care_middle": ({"dla_sc_category": "MIDDLE"}, True), + "dla_care_highest": ({"dla_sc_category": "HIGHER"}, True), + "pip_dl_standard": ({"pip_dl_category": "STANDARD"}, True), + "pip_dl_enhanced": ({"pip_dl_category": "ENHANCED"}, True), +} + + +@st.composite +def person(draw, min_age, max_age): + age = draw(st.integers(min_age, max_age)) + return dict( + age=age, + benefit=draw(st.sampled_from(sorted(BENEFITS))), + blind=draw(st.booleans()), + carer=draw(st.booleans()), + # Only meaningful at 16 to 19 (reg 4A); started before 19. + education=16 <= age <= 19 and draw(st.booleans()), + ) + + +@st.composite +def household(draw): + focal = dict( + claimants=[draw(person(66, 95)) for _ in range(draw(st.integers(1, 2)))], + dependants=draw(st.lists(person(0, 19), max_size=2)), + ) + others = [ + dict(claimants=[draw(person(16, 90))], dependants=[]) + for _ in range(draw(st.integers(0, 2))) + ] + return [focal] + others + + +def build(households): + """One Simulation for many households; returns the situation and a map.""" + people, benunits, hh = {}, {}, {} + layout = [] + for h, units in enumerate(households): + hh_members, hh_layout = [], [] + for b, unit in enumerate(units): + names = [] + for role, group in (("cp", unit["claimants"]), ("dep", unit["dependants"])): + for j, p in enumerate(group): + name = f"h{h}b{b}{role}{j}" + record = { + "age": {YEAR: p["age"]}, + "is_blind": {YEAR: p["blind"]}, + "is_claimant_or_partner": {YEAR: role == "cp"}, + "is_in_non_advanced_education": {YEAR: p["education"]}, + "age_started_or_accepted_current_education_or_training": { + YEAR: min(p["age"], 18) + }, + "care_hours": {YEAR: 35 if p["carer"] else 0}, + "carers_allowance_reported": {YEAR: 1 if p["carer"] else 0}, + } + for variable, value in BENEFITS[p["benefit"]][0].items(): + record[variable] = {YEAR: value} + people[name] = record + names.append((name, role, p)) + benunits[f"h{h}b{b}"] = {"members": [n for n, _, _ in names]} + hh_members += [n for n, _, _ in names] + hh_layout.append(names) + hh[f"h{h}"] = {"members": hh_members, "country": {YEAR: "ENGLAND"}} + layout.append(hh_layout) + return {"people": people, "benunits": benunits, "households": hh}, layout + + +def simulate(households): + situation, layout = build(households) + simulation = Simulation(situation=situation) + + def calc(variable): + return np.asarray(simulation.calculate(variable, YEAR)) + + rate = float( + simulation.tax_benefit_system.parameters( + str(YEAR) + ).gov.dwp.pension_credit.guarantee_credit.severe_disability.addition + ) + return { + "layout": layout, + "rates": calc("severe_disability_minimum_guarantee_addition") + / (rate * WEEKS_IN_YEAR), + "qualifies": calc("receives_severe_disability_addition_qualifying_benefit"), + "carer": calc("receives_carer_benefit"), + "is_couple": calc("is_couple"), + } + + +def rates_by_household(result): + """Split the benefit-unit rates array into one list per household.""" + out, i = [], 0 + for hh_layout in result["layout"]: + out.append(list(result["rates"][i : i + len(hh_layout)])) + i += len(hh_layout) + return out + + +def reference_household(result, h): + """The reference's input for household h, from the simulation's inputs.""" + person_index = {} + k = 0 + for layout in result["layout"]: + for names in layout: + for name, _, _ in names: + person_index[name] = k + k += 1 + people, benunits = {}, {} + for b, names in enumerate(result["layout"][h]): + benunits[b] = {"claimant_or_partner": [], "others": []} + for name, role, p in names: + i = person_index[name] + people[name] = { + "age": p["age"], + "qualifying_benefit": BENEFITS[p["benefit"]][1], + "blind": p["blind"], + # Reg 4A, from the inputs: 16 to 19, in non-advanced + # education started before 19, no benefits in own right. + "qualifying_young_person": 16 <= p["age"] <= 19 and p["education"], + "receives_carer_benefit": bool(result["carer"][i]), + } + key = "claimant_or_partner" if role == "cp" else "others" + benunits[b][key].append(name) + return {"people": people, "benunits": benunits} + + +@PROPERTY_SETTINGS +@given(st.lists(household(), min_size=1, max_size=6)) +def test_addition_matches_the_reference_and_is_bounded(households): + result = simulate(households) + rates = rates_by_household(result) + np.testing.assert_allclose(result["rates"], np.round(result["rates"]), atol=1e-9) + assert np.all(np.isin(np.round(result["rates"]), [0, 1, 2])) + assert np.all(np.round(result["rates"])[~result["is_couple"].astype(bool)] <= 1) + # The qualifying-benefit flag follows the para 1(1)(a)(i) list. + expected_qualifies = [ + BENEFITS[p["benefit"]][1] + for layout in result["layout"] + for names in layout + for _, _, p in names + ] + np.testing.assert_array_equal(result["qualifies"], expected_qualifies) + for h, household_units in enumerate(households): + reference = reference_household(result, h) + expected = reference_rates(reference) + actual = [int(round(x)) for x in rates[h]] + assert actual == [expected[b] for b in range(len(household_units))], ( + reference, + actual, + ) + + +IGNORED_RESIDENTS = [ + dict(age=50, benefit="pip_dl_standard", blind=False, carer=False, education=False), + dict(age=50, benefit="aa_lower", blind=False, carer=False, education=False), + dict(age=50, benefit="none", blind=True, carer=False, education=False), + dict(age=18, benefit="none", blind=False, carer=False, education=True), + dict(age=17, benefit="none", blind=False, carer=False, education=False), +] +COUNTED_RESIDENT = dict( + age=30, benefit="none", blind=False, carer=False, education=False +) + + +@PROPERTY_SETTINGS +@given(household(), st.sampled_from(range(len(IGNORED_RESIDENTS)))) +def test_residence_condition_is_metamorphic(original, ignored_index): + ignored = IGNORED_RESIDENTS[ignored_index] + with_ignored = original + [dict(claimants=[ignored], dependants=[])] + with_counted = original + [dict(claimants=[COUNTED_RESIDENT], dependants=[])] + result = simulate([original, with_ignored, with_counted]) + base, ignored_rates, counted_rates = rates_by_household(result) + n = len(original) + np.testing.assert_allclose(ignored_rates[:n], base, atol=1e-9) + np.testing.assert_allclose(counted_rates[:n], 0, atol=1e-9) + + +@PROPERTY_SETTINGS +@given(household(), st.data()) +def test_a_carer_benefit_never_increases_the_addition(original, data): + candidates = [ + (u, group, j) + for u, unit in enumerate(original) + for group in ("claimants", "dependants") + for j, p in enumerate(unit[group]) + if not p["carer"] and p["age"] >= 16 + ] + if not candidates: + return + u, group, j = data.draw(st.sampled_from(candidates)) + changed = [ + dict( + claimants=[dict(p) for p in unit["claimants"]], + dependants=[dict(p) for p in unit["dependants"]], + ) + for unit in original + ] + changed[u][group][j]["carer"] = True + result = simulate([original, changed]) + before, after = rates_by_household(result) + assert all(a <= b + 1e-9 for a, b in zip(after, before)), (before, after) + + +@PROPERTY_SETTINGS +@given(household()) +def test_swapping_partners_leaves_a_couples_addition_unchanged(original): + focal = original[0] + if len(focal["claimants"]) != 2: + return + first, second = focal["claimants"] + keys = ["benefit", "blind", "carer"] + swapped_first = {**first, **{k: second[k] for k in keys}} + swapped_second = {**second, **{k: first[k] for k in keys}} + swapped = [dict(focal, claimants=[swapped_first, swapped_second])] + original[1:] + result = simulate([original, swapped]) + before, after = rates_by_household(result) + assert before[0] == after[0] From 154d22b0a6ae075e4aca5f47d3d257db1a7b001d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:19:17 -0400 Subject: [PATCH 4/6] Allocate each carer award to one person in the household; list HB divergences SSCBA 1992 s.70(7) allows one carer's allowance per carer, so a carer outside a benefit unit no longer counts against every other unit in the household. The household's carers not caring within their own unit go to qualifying claimants or partners not yet cared for, one each, taking the units in order of their eldest member. The reference oracle and the metamorphic property follow; add YAML cases for the residence probes and the allocation. The documentation no longer says pension-age Housing Benefit applies the same test: HB counts an 18- or 19-year-old young person of another family as a non-dependant, and needs the claimant to qualify where the partner is blind. Co-Authored-By: Claude Opus 5.5 --- ...evere_disability_residence_and_carers.yaml | 263 ++++++++++++++++++ .../severe_disability_addition_reference.py | 149 +++++----- ...t_severe_disability_addition_properties.py | 33 ++- ...disability_addition_residence_condition.py | 7 +- ...isability_addition_qualifiers_cared_for.py | 72 +++-- ...e_disability_minimum_guarantee_addition.py | 11 +- 6 files changed, 424 insertions(+), 111 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml index d85b47d438..e6fd8005c5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -452,3 +452,266 @@ output: # Standard minimum guarantee only: 238.00 x 52 = 12,376. minimum_guarantee: [12_376, 12_376] + +- name: A resident on the DLA care component at the middle rate or on AFIP is ignored, but not one on the lowest rate or on PIP mobility only + # Para 2(2)(a) lists DLA care at the highest or middle rate, PIP daily + # living and Armed Forces Independence Payment; other awards do not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant_1: + age: 80 + aa_category: HIGHER + resident_1: + age: 40 + dla_sc_category: MIDDLE + claimant_2: + age: 80 + aa_category: HIGHER + resident_2: + age: 40 + armed_forces_independence_payment: 1_000 + claimant_3: + age: 80 + aa_category: HIGHER + resident_3: + age: 40 + dla_sc_category: LOWER + claimant_4: + age: 80 + aa_category: HIGHER + resident_4: + age: 40 + pip_m_category: ENHANCED + benunits: + unit_1: + members: [claimant_1] + resident_unit_1: + members: [resident_1] + unit_2: + members: [claimant_2] + resident_unit_2: + members: [resident_2] + unit_3: + members: [claimant_3] + resident_unit_3: + members: [resident_3] + unit_4: + members: [claimant_4] + resident_unit_4: + members: [resident_4] + households: + household_1: + members: [claimant_1, resident_1] + household_2: + members: [claimant_2, resident_2] + household_3: + members: [claimant_3, resident_3] + household_4: + members: [claimant_4, resident_4] + output: + is_counted_resident_for_severe_disability_addition: + [false, false, false, false, false, true, false, true] + # The middle-rate and AFIP residents qualify in their own right too. + severe_disability_minimum_guarantee_addition: + [4_474.60, 4_474.60, 4_474.60, 4_474.60, 0, 0, 0, 0] + +- name: A 19-year-old who started their course at 19 is not a qualifying young person and counts + # Reg 4A: the education must have started, or been enrolled on or accepted, + # before 19. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + is_parent: true + aa_category: HIGHER + young_person: + age: 19 + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 19 + benunits: + benunit: + members: [claimant, young_person] + households: + household: + members: [claimant, young_person] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + severe_disability_minimum_guarantee_addition: 0 + +- name: A qualifying young person in another benefit unit is ignored for Pension Credit + # Para 2(2)(f) ignores any qualifying young person within reg 4A, not only + # the claimant's own. The young person's parent receives the PIP daily + # living component, so para 2(2)(a) ignores her too. (Housing Benefit counts + # a young person of another family as a non-dependant: HB(SPC) reg 3(2)(a).) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + aa_category: HIGHER + parent: + age: 45 + is_parent: true + pip_dl_category: STANDARD + grandchild: + age: 18 + is_in_non_advanced_education: true + benunits: + claimant_unit: + members: [claimant] + parent_unit: + members: [parent, grandchild] + households: + household: + members: [claimant, parent, grandchild] + output: + severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] + +- name: A third adult in the couple's own benefit unit counts unless their presence is ignored + # Supplying is_claimant_or_partner replaces the formula for everyone, so + # it is set for each member. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant_1: + age: 78 + aa_category: HIGHER + is_parent: true + is_claimant_or_partner: true + partner_1: + age: 76 + aa_category: HIGHER + is_parent: true + is_claimant_or_partner: true + adult_1: + age: 45 + is_claimant_or_partner: false + claimant_2: + age: 78 + aa_category: HIGHER + is_parent: true + is_claimant_or_partner: true + partner_2: + age: 76 + aa_category: HIGHER + is_parent: true + is_claimant_or_partner: true + adult_2: + age: 45 + pip_dl_category: STANDARD + is_claimant_or_partner: false + benunits: + couple_1: + members: [claimant_1, partner_1, adult_1] + couple_2: + members: [claimant_2, partner_2, adult_2] + households: + household_1: + members: [claimant_1, partner_1, adult_1] + household_2: + members: [claimant_2, partner_2, adult_2] + output: + severe_disability_minimum_guarantee_addition: [0, 8_949.20] + +- name: One carer's award covers one person, not every benefit unit in the household + # SSCBA 1992 s.70(7): one carer's allowance per carer. Two single pensioners + # on Attendance Allowance share a house with a 17-year-old who receives + # Carer's Allowance. The award is attributed to the elder pensioner only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + younger: + age: 70 + aa_category: HIGHER + elder: + age: 72 + aa_category: HIGHER + carer: + age: 17 + care_hours: 35 + carers_allowance_reported: 1 + benunits: + younger_unit: + members: [younger] + elder_unit: + members: [elder] + carer_unit: + members: [carer] + households: + household: + members: [younger, elder, carer] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [0, 1, 0] + severe_disability_minimum_guarantee_addition: [4_474.60, 0, 0] + +- name: Two carers in the household cover two benefit units + period: 2026 + absolute_error_margin: 0 + input: + people: + younger: + age: 70 + aa_category: HIGHER + elder: + age: 72 + aa_category: HIGHER + carer_1: + age: 17 + care_hours: 35 + carers_allowance_reported: 1 + carer_2: + age: 16 + care_hours: 35 + carers_allowance_reported: 1 + benunits: + younger_unit: + members: [younger] + elder_unit: + members: [elder] + carer_unit_1: + members: [carer_1] + carer_unit_2: + members: [carer_2] + households: + household: + members: [younger, elder, carer_1, carer_2] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 1, 0, 0] + severe_disability_minimum_guarantee_addition: [0, 0, 0, 0] + +- name: Two single pensioners who each receive Carer's Allowance care for each other + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 72 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + second: + age: 70 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + benunits: + first_unit: + members: [first] + second_unit: + members: [second] + households: + household: + members: [first, second] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 1] + severe_disability_minimum_guarantee_addition: [0, 0] diff --git a/policyengine_uk/tests/severe_disability_addition_reference.py b/policyengine_uk/tests/severe_disability_addition_reference.py index 951735cd3c..b2689ffdfc 100644 --- a/policyengine_uk/tests/severe_disability_addition_reference.py +++ b/policyengine_uk/tests/severe_disability_addition_reference.py @@ -42,14 +42,13 @@ household other than this unit's claimant/partner is aged 18 or over and is not a para 2 person. Modelled para 2 persons: qualifying benefit (2(2)(a)), blind (2(2)(b)), qualifying young person (2(2)(f)). No other exceptions. -3. Carers. Nobody cares for themselves. A claimant/partner who receives a carer - benefit cares for their partner if the partner qualifies; otherwise they care - for someone outside the unit. Any other member of a unit who receives a carer - benefit cares for a qualifying claimant/partner of their own unit if it has - one. Every remaining carer in the household (one not caring within their own - unit) cares for a qualifying claimant/partner of THIS unit. Each such carer - cares for a different qualifying person who is not already cared for, until - every qualifying claimant/partner is cared for. +3. Carers [as amended]. Nobody cares for themselves, and each award is for one + person. A claimant/partner who receives a carer benefit cares for their + partner if the partner qualifies. Any other member of a unit who receives a + carer benefit cares for a qualifying claimant/partner of their own unit if + it has one. The household's remaining carers each care for a different + qualifying claimant/partner of another unit who is not yet cared for; the + units take them in turn, eldest member first. 4. Heads. Para 1(1)(a) applies to singles; (b) and then (c) apply to couples. (c) applies only where (b) does not, and (c)(iv) needs no carer for the partner to whom (c)(i) applies. If both partners qualify, either may be the @@ -175,80 +174,87 @@ def residence_condition_met( # --------------------------------------------------------------------------- -def _cares_within_other_unit(people, units, pid, unit_id) -> bool: - """[Rule 3 as amended] Whether ``pid``, a member of a unit other than - ``unit_id``, cares for a qualifying claimant or partner of their own unit.""" - for other_id, other in units.items(): - if other_id == unit_id: - continue - members = list(other["claimant_or_partner"]) + list(other.get("others", [])) - if pid not in members: - continue - return any( - qualifies(people[q]) for q in other["claimant_or_partner"] if q != pid - ) - return False +def _members(unit: Mapping[str, Any]) -> list: + return list(unit["claimant_or_partner"]) + list(unit.get("others", [])) -def carer_assignments( - people: Mapping[PersonId, Person], - claimant_or_partner: Sequence[PersonId], - units: Mapping[Hashable, Any] = None, - unit_id: Hashable = None, -) -> list[frozenset]: - """Every allowed set of this unit's claimant/partners who are cared for. +def _cares_within_own_unit(people, unit, pid) -> bool: + """[Rule 3 as amended] A carer cares within their own unit if it has a + qualifying claimant or partner other than the carer.""" + return any(qualifies(people[q]) for q in unit["claimant_or_partner"] if q != pid) + + +def household_carer_assignments(household: Mapping[str, Any]) -> dict: + """Every allowed set of cared-for claimant/partners, for each unit. The carer conditions are para 1(1)(a)(iii), (b)(ii) second limb, (c)(iv) and reg 6(5)(b). Each asks whether a person "is entitled to and in receipt of an allowance under section 70 of the 1992 Act (carer's allowance) or carer support payment , or has an award of universal credit which includes the carer - element, in respect of caring for" a named person. + element, in respect of caring for" a named person. SSCBA 1992 s.70(7): "No + person shall be entitled for the same day to (a) more than one allowance + under this section", so each award is for one person. - Spec rule 3: + Rule 3 [as amended]: * Nobody cares for themselves. * A claimant/partner who receives a carer benefit cares for their partner if - the partner qualifies; otherwise they care for someone outside the unit. - * Every other household member (an "other" in this unit or anyone in another - unit) who receives a carer benefit cares for a qualifying claimant/partner - of THIS unit. Each cares for a different qualifying person who is not - already cared for, until every qualifying claimant/partner is cared for. - - Rule 3 does not say which partner an outside carer takes when both qualify and - neither is yet cared for. This function returns every allowed assignment, and + the partner qualifies. Any other member of a unit who receives one cares + for a qualifying claimant/partner of that unit if there is one. + * Every remaining carer in the household cares for a qualifying + claimant/partner of another unit who is not yet cared for, one each. The + units take them in turn, eldest member first (ties by listing order); + each unit takes as many as it has uncared-for qualifiers, from carers + outside it, while any remain. + + Rule 3 does not say which partner a carer takes when both qualify and + neither is yet cared for. This returns every allowed set, and ``reference_rates`` asserts that the answer is the same for all of them. """ - units = units or {} - unit = list(claimant_or_partner) - qualifying = [pid for pid in unit if qualifies(people[pid])] - - # Partner carers: A cares for B only when B qualifies. A claimant/partner is - # never their own carer, and in a single unit has no partner to care for. - cared_by_partner = set() - if len(unit) == 2: - first, second = unit - for carer, cared in ((first, second), (second, first)): - if people[carer]["receives_carer_benefit"] and qualifies(people[cared]): - cared_by_partner.add(cared) - - # Outside carers: every household member who is not this unit's claimant or - # partner and who is not caring within their own (other) unit. [Rule 3 as - # amended: a carer in another unit cares there if that unit has a - # qualifying claimant or partner other than the carer.] - own = set(unit) - outside_carers = sum( - 1 - for pid, person in people.items() - if pid not in own - and person["receives_carer_benefit"] - and not _cares_within_other_unit(people, units, pid, unit_id) - ) + people = household["people"] + units = household["benunits"] + position = {pid: i for i, pid in enumerate(people)} + cared_by_partner, uncovered, qualifying, outside = {}, {}, {}, {} + for bu_id, unit in units.items(): + cp = list(unit["claimant_or_partner"]) + qualifying[bu_id] = [pid for pid in cp if qualifies(people[pid])] + carers = [p for p in _members(unit) if people[p]["receives_carer_benefit"]] + within = [p for p in carers if _cares_within_own_unit(people, unit, p)] + outside[bu_id] = len(carers) - len(within) + # A claimant/partner caring within the unit cares for their partner. + cared_by_partner[bu_id] = { + other for p in within if p in cp for other in cp if other != p + } + by_others = len([p for p in within if p not in cp]) + covered = min(len(qualifying[bu_id]), len(cared_by_partner[bu_id]) + by_others) + uncovered[bu_id] = len(qualifying[bu_id]) - covered + + def eldest_key(bu_id): + members = _members(units[bu_id]) + oldest = max(people[p]["age"] for p in members) + first = min(position[p] for p in members if people[p]["age"] == oldest) + return (-oldest, first) + + total_outside = sum(outside.values()) + allocated, from_other_units = 0, {} + for bu_id in sorted(units, key=eldest_key): + available = max( + 0, min(total_outside - outside[bu_id], total_outside - allocated) + ) + from_other_units[bu_id] = min(uncovered[bu_id], available) + allocated += from_other_units[bu_id] - still_uncared = [pid for pid in qualifying if pid not in cared_by_partner] - newly_cared = min(outside_carers, len(still_uncared)) - return [ - frozenset(cared_by_partner) | frozenset(choice) - for choice in combinations(still_uncared, newly_cared) - ] + result = {} + for bu_id in units: + cared_count = ( + len(qualifying[bu_id]) - uncovered[bu_id] + from_other_units[bu_id] + ) + must = frozenset(cared_by_partner[bu_id]) + rest = [q for q in qualifying[bu_id] if q not in must] + result[bu_id] = [ + must | frozenset(choice) + for choice in combinations(rest, cared_count - len(must)) + ] + return result # --------------------------------------------------------------------------- @@ -434,13 +440,12 @@ def reference_rates(household: Mapping[str, Any]) -> dict: _validate(household) people = household["people"] result = {} + assignments = household_carer_assignments(household) for bu_id, unit in household["benunits"].items(): claimant_or_partner = list(unit["claimant_or_partner"]) outcomes = { rates_for_unit(people, claimant_or_partner, cared_for) - for cared_for in carer_assignments( - people, claimant_or_partner, household["benunits"], bu_id - ) + for cared_for in assignments[bu_id] } # Rule 3 leaves open which qualifying partner an outside carer takes. The # law makes that choice immaterial: (b) depends only on how many partners diff --git a/policyengine_uk/tests/test_severe_disability_addition_properties.py b/policyengine_uk/tests/test_severe_disability_addition_properties.py index bf84fa49ca..8886e9241a 100644 --- a/policyengine_uk/tests/test_severe_disability_addition_properties.py +++ b/policyengine_uk/tests/test_severe_disability_addition_properties.py @@ -17,10 +17,12 @@ 2 only for a couple. The qualifying-benefit flag matches the Sch I para 1(1)(a)(i) list for the drawn benefit categories. 3. Residence, metamorphic: adding another adult resident who is not a para 2 - person removes the addition for every other benefit unit; adding a resident - whose presence is ignored (a qualifying-benefit recipient, a blind person, - a qualifying young person or anyone under 18) who receives no carer benefit - leaves every existing unit's addition unchanged. + person removes the addition for every other benefit unit. Adding a resident + whose presence is ignored and who receives no carer benefit leaves every + existing unit's addition unchanged if the resident does not qualify (a + blind person, a qualifying young person or anyone under 18), and never + lowers it if they do (a qualifying-benefit recipient, to whom a carer in + the household may then be attributed instead). 4. Carers, metamorphic: giving someone a carer benefit never increases any unit's addition. 5. Symmetry: swapping the drawn attributes of a couple's two partners leaves @@ -200,12 +202,18 @@ def test_addition_matches_the_reference_and_is_bounded(households): ) +# (resident, whether they receive a para 1(1)(a)(i) qualifying benefit) IGNORED_RESIDENTS = [ - dict(age=50, benefit="pip_dl_standard", blind=False, carer=False, education=False), - dict(age=50, benefit="aa_lower", blind=False, carer=False, education=False), - dict(age=50, benefit="none", blind=True, carer=False, education=False), - dict(age=18, benefit="none", blind=False, carer=False, education=True), - dict(age=17, benefit="none", blind=False, carer=False, education=False), + ( + dict( + age=50, benefit="pip_dl_standard", blind=False, carer=False, education=False + ), + True, + ), + (dict(age=50, benefit="aa_lower", blind=False, carer=False, education=False), True), + (dict(age=50, benefit="none", blind=True, carer=False, education=False), False), + (dict(age=18, benefit="none", blind=False, carer=False, education=True), False), + (dict(age=17, benefit="none", blind=False, carer=False, education=False), False), ] COUNTED_RESIDENT = dict( age=30, benefit="none", blind=False, carer=False, education=False @@ -215,13 +223,16 @@ def test_addition_matches_the_reference_and_is_bounded(households): @PROPERTY_SETTINGS @given(household(), st.sampled_from(range(len(IGNORED_RESIDENTS)))) def test_residence_condition_is_metamorphic(original, ignored_index): - ignored = IGNORED_RESIDENTS[ignored_index] + ignored, ignored_qualifies = IGNORED_RESIDENTS[ignored_index] with_ignored = original + [dict(claimants=[ignored], dependants=[])] with_counted = original + [dict(claimants=[COUNTED_RESIDENT], dependants=[])] result = simulate([original, with_ignored, with_counted]) base, ignored_rates, counted_rates = rates_by_household(result) n = len(original) - np.testing.assert_allclose(ignored_rates[:n], base, atol=1e-9) + if ignored_qualifies: + assert all(a >= b - 1e-9 for a, b in zip(ignored_rates[:n], base)) + else: + np.testing.assert_allclose(ignored_rates[:n], base, atol=1e-9) np.testing.assert_allclose(counted_rates[:n], 0, atol=1e-9) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py index 9742f92e5f..a140b66627 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_severe_disability_addition_residence_condition.py @@ -10,9 +10,10 @@ class meets_severe_disability_addition_residence_condition(Variable): "normally resides with the claimant (and partner). Everyone else in the " "household counts, including a member of the benefit unit who is " "neither the claimant, the partner nor a qualifying young person. " - "Housing Benefit and Council Tax Reduction for pension-age claimants " - "apply the same test to non-dependants (HB(SPC) Regs 2006 Sch 3 para " - "6(2)(a)(ii), (b)(iii) and (6))." + "The pension-age Housing Benefit severe disability premium applies a " + "similar test to non-dependants (HB(SPC) Regs 2006 Sch 3 para " + "6(2)(a)(ii), (b)(iii) and (6)), which also counts a young person of " + "another family." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py index 9ff54ff8f4..e13ca7048a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py @@ -11,26 +11,31 @@ class num_severe_disability_addition_qualifiers_cared_for(Variable): "How many of the claimant and partner who receive a qualifying " "disability benefit have someone entitled to and in receipt of " "Carer's Allowance or Carer Support Payment in respect of caring for " - "them. The data do not say whom a carer cares for, and a carer benefit " + "them. The data do not say whom a carer cares for. A carer benefit " "needs the person cared for to receive a qualifying disability " - "benefit, so each carer is attributed as follows. A claimant or partner " - "cares for their partner if the partner qualifies; any other member of " - "a benefit unit cares for a qualifying claimant or partner of that " - "unit if there is one. No one is their own carer. Every other carer " - "benefit in the household is treated as paid for a qualifying claimant " - "or partner of each other benefit unit there, each carer for a " - "different one. Carers outside the household, and Universal Credit " - "awards that include the carer element, are not counted. Reading " - "Universal Credit here would be circular, because the Universal " - "Credit non-dependant deduction exemption reads Pension Credit; a " - "Universal Credit carer in the household already stops the addition " - "through the residence condition unless their presence is ignored." + "benefit, and each award is for one person (SSCBA 1992 s.70(7)), so " + "each carer is attributed as follows. A claimant or partner cares for " + "their partner if the partner qualifies; any other member of a benefit " + "unit cares for a qualifying claimant or partner of that unit if there " + "is one. No one is their own carer. The household's other carers are " + "allocated to qualifying claimants or partners not yet cared for in " + "the other benefit units, one each, taking the units in descending " + "order of their eldest member's age. Carers outside the household, and " + "Universal Credit awards that include the carer element, are not " + "counted. Reading Universal Credit here would be circular, because the " + "Universal Credit non-dependant deduction exemption reads Pension " + "Credit; a Universal Credit carer in the household already stops the " + "addition through the residence condition unless their presence is " + "ignored. Receipt is receives_carer_benefit, so it inherits " + "carers_allowance, which does not apply the overlapping-benefit rule " + "that stops Carer's Allowance being paid alongside a higher State " + "Pension." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", - "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", ) def formula(benunit, period, parameters): @@ -41,13 +46,38 @@ def formula(benunit, period, parameters): ) carer = person("receives_carer_benefit", period) qualifying = benunit.sum(qualifies) - # Qualifying claimants or partners in the carer's own unit, other than - # the carer. + # Carers within their own unit: there is a qualifying claimant or + # partner in the carer's unit other than the carer. qualifiers_in_own_unit = benunit.project(qualifying) - qualifies cares_within_unit = carer & (qualifiers_in_own_unit > 0) + cared_for_within = min_(qualifying, benunit.sum(cares_within_unit)) + not_yet_cared_for = qualifying - cared_for_within + # Every other carer benefit is for someone in another unit. cares_outside_unit = carer & ~cares_within_unit - within_unit = benunit.sum(cares_within_unit) - from_other_units = benunit.max( - person.household.sum(cares_outside_unit) - ) - benunit.sum(cares_outside_unit) - return min_(qualifying, within_unit + from_other_units) + outside_here = benunit.sum(cares_outside_unit) + outside_in_household = benunit.max(person.household.sum(cares_outside_unit)) + # Order the household's units by their eldest member (ties by position) + # and give each unit, in turn, carers from the other units. + age = person("age", period) + representative = person.get_rank(person.benunit, -age) == 0 + order = person.get_rank(person.household, -age, condition=representative) + unit_order = benunit.max(where(representative, order, -1)) + allocated_in_household = np.zeros_like(outside_here) + cared_for_from_other_units = np.zeros_like(outside_here) + for rank in range(int(np.max(unit_order, initial=-1)) + 1): + available = max_( + 0, + min_( + outside_in_household - outside_here, + outside_in_household - allocated_in_household, + ), + ) + allocated = where(unit_order == rank, min_(not_yet_cared_for, available), 0) + cared_for_from_other_units = cared_for_from_other_units + allocated + allocated_by_representative = where( + representative, benunit.project(allocated), 0 + ) + allocated_in_household = allocated_in_household + benunit.max( + person.household.sum(allocated_by_representative) + ) + return cared_for_within + cared_for_from_other_units 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 0c0cd67901..1721010ad1 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 @@ -15,10 +15,13 @@ class severe_disability_minimum_guarantee_addition(Variable): "is certified blind or severely sight impaired, no other resident " "counts, and no carer benefit is paid for the first (para 1(1)(c)). " "The amount is two rates for a couple under (b) with no carer benefit " - "paid for either partner, and one rate otherwise (reg 6(5)). Pension-age " - "Housing Benefit and Council Tax Reduction read this variable for " - "their severe disability premium (HB(SPC) Regs 2006 Sch 3 paras 6 and " - "12(1)). Not modelled: the hospital-patient deeming (para 1(2)(b)-(bd)) " + "paid for either partner, and one rate otherwise (reg 6(5)). The " + "pension-age Housing Benefit severe disability premium (HB(SPC) Regs " + "2006 Sch 3 paras 6 and 12(1)) has nearly the same conditions and the " + "same amounts, but counts an 18- or 19-year-old young person of " + "another family as a non-dependant, and needs the claimant, not " + "either partner, to qualify where the other partner is blind. Not " + "modelled: the hospital-patient deeming (para 1(2)(b)-(bd)) " "and backdating (para 1(2)(a), (c)); sight regained within 28 weeks " "(para 1(3)); polygamous marriages; and the unmodelled para 2 and 3 " "exceptions and carer attribution described on " From 214a2fade4407de34fc6f2a2108c86b2336dc371 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 18:40:56 -0400 Subject: [PATCH 5/6] Address review: floor the rate count, attribute carers to disabled dependants, pool surplus carers - Never let the addition go negative when more than two members are supplied as claimant or partner. - A carer cares first for a qualifying claimant or partner in their own unit, then for another member who receives a qualifying benefit (such as a disabled child); only carers left over go to other benefit units. Each person has one carer (SSCBA 1992 s.70(7ZA)), so extra carers in a unit join the household pool. - Document the age-tie order. Test the age-18 boundary, the bound, the disabled-child and surplus-carer cases and the tie order. - The reference oracle now attributes carers by explicit person-to-person matching rather than the implementation's counting; the property generator draws couples and dependants in every benefit unit. Co-Authored-By: Claude Opus 5.5 --- ...evere_disability_residence_and_carers.yaml | 156 ++++++++++++++++++ .../severe_disability_addition_reference.py | 144 +++++++++++----- ...t_severe_disability_addition_properties.py | 13 +- ...isability_addition_qualifiers_cared_for.py | 53 ++++-- ...e_disability_minimum_guarantee_addition.py | 2 +- 5 files changed, 300 insertions(+), 68 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml index e6fd8005c5..89ccd6823d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -715,3 +715,159 @@ output: num_severe_disability_addition_qualifiers_cared_for: [1, 1] severe_disability_minimum_guarantee_addition: [0, 0] + +- name: A resident who has just attained 18 and is not a qualifying young person counts + # Para 1(1)(a)(ii): "no person who has attained the age of 18". + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 75 + aa_category: HIGHER + grandchild: + age: 18 + benunits: + claimant_unit: + members: [claimant] + grandchild_unit: + members: [grandchild] + households: + household: + members: [claimant, grandchild] + output: + is_counted_resident_for_severe_disability_addition: [false, true] + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: The addition is never negative, even with more claimant or partner flags than a couple has + # Supplied inputs: three members flagged as claimant or partner, all on + # Attendance Allowance and Carer's Allowance. + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + is_claimant_or_partner: true + second: + age: 74 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + is_claimant_or_partner: true + third: + age: 73 + aa_category: HIGHER + care_hours: 35 + carers_allowance_reported: 1 + is_claimant_or_partner: true + benunits: + benunit: + members: [first, second, third] + households: + household: + members: [first, second, third] + country: ENGLAND + output: + severe_disability_minimum_guarantee_addition: 0 + +- name: A carer whose own disabled child qualifies is treated as caring for the child + # The daughter receives Carer's Allowance; her child receives the DLA care + # component at the middle rate, so she is attributed to her child, not to + # the grandmother in another benefit unit (each award is for one person). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + aa_category: HIGHER + daughter: + age: 45 + is_parent: true + pip_dl_category: STANDARD + care_hours: 35 + carers_allowance_reported: 1 + child: + age: 10 + dla_sc_category: MIDDLE + benunits: + grandmother_unit: + members: [grandmother] + daughter_unit: + members: [daughter, child] + households: + household: + members: [grandmother, daughter, child] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [0, 0] + severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] + +- name: A second carer in a pensioner's unit is attributed to a pensioner in another unit + # Each person has only one carer (SSCBA 1992 s.70(7ZA)), so the second of + # two young carers in the first pensioner's unit cares for the second + # pensioner. The result is the same whichever unit the carers are in. + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 80 + aa_category: HIGHER + carer_1: + age: 17 + care_hours: 35 + carers_allowance_reported: 1 + carer_2: + age: 16 + care_hours: 35 + carers_allowance_reported: 1 + second: + age: 78 + aa_category: HIGHER + benunits: + first_unit: + members: [first, carer_1, carer_2] + second_unit: + members: [second] + households: + household: + members: [first, carer_1, carer_2, second] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 1] + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: When two benefit units' eldest members are the same age, the one listed first takes a carer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + listed_first: + age: 75 + aa_category: HIGHER + listed_second: + age: 75 + aa_category: HIGHER + carer: + age: 17 + care_hours: 35 + carers_allowance_reported: 1 + benunits: + first_unit: + members: [listed_first] + second_unit: + members: [listed_second] + carer_unit: + members: [carer] + households: + household: + members: [listed_first, listed_second, carer] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 0, 0] + severe_disability_minimum_guarantee_addition: [0, 4_474.60, 0] diff --git a/policyengine_uk/tests/severe_disability_addition_reference.py b/policyengine_uk/tests/severe_disability_addition_reference.py index b2689ffdfc..07273f664d 100644 --- a/policyengine_uk/tests/severe_disability_addition_reference.py +++ b/policyengine_uk/tests/severe_disability_addition_reference.py @@ -42,13 +42,14 @@ household other than this unit's claimant/partner is aged 18 or over and is not a para 2 person. Modelled para 2 persons: qualifying benefit (2(2)(a)), blind (2(2)(b)), qualifying young person (2(2)(f)). No other exceptions. -3. Carers [as amended]. Nobody cares for themselves, and each award is for one - person. A claimant/partner who receives a carer benefit cares for their - partner if the partner qualifies. Any other member of a unit who receives a - carer benefit cares for a qualifying claimant/partner of their own unit if - it has one. The household's remaining carers each care for a different - qualifying claimant/partner of another unit who is not yet cared for; the - units take them in turn, eldest member first. +3. Carers [as amended]. Nobody cares for themselves, each award is for one + person, and each person has at most one carer. Within their own unit, + carers care for qualifying claimants/partners first and then other + qualifying members (such as a disabled child). Every carer left over cares + for a qualifying claimant/partner of another unit who is not yet cared + for; the units take them in turn, eldest member first. This rule is + implemented as explicit person-to-person matching, independently of the + implementation's counting. 4. Heads. Para 1(1)(a) applies to singles; (b) and then (c) apply to couples. (c) applies only where (b) does not, and (c)(iv) needs no carer for the partner to whom (c)(i) applies. If both partners qualify, either may be the @@ -178,10 +179,60 @@ def _members(unit: Mapping[str, Any]) -> list: return list(unit["claimant_or_partner"]) + list(unit.get("others", [])) -def _cares_within_own_unit(people, unit, pid) -> bool: - """[Rule 3 as amended] A carer cares within their own unit if it has a - qualifying claimant or partner other than the carer.""" - return any(qualifies(people[q]) for q in unit["claimant_or_partner"] if q != pid) +def _best_within_unit(people, unit): + """[Rule 3 as amended, within a unit] Match carers to people they care for. + + Each carer cares for at most one person in their own unit who receives a + qualifying benefit, never themselves; each such person has at most one + carer. Among all such matchings, take one that cares for the most + qualifying claimants/partners, and then the most people. Enumerated by + brute force (units are small). Returns (claimant/partners who must be cared + for in every best matching, the number cared for, carers left unmatched). + """ + cp = list(unit["claimant_or_partner"]) + members = _members(unit) + carers = [p for p in members if people[p]["receives_carer_benefit"]] + targets = [p for p in members if qualifies(people[p])] + best_key, best = None, [] + + def search(i, used, pairs): + nonlocal best_key, best + if i == len(carers): + covered = {t for _, t in pairs} + key = (len(covered & set(cp)), len(covered)) + if best_key is None or key > best_key: + best_key, best = key, [covered] + elif key == best_key: + best.append(covered) + return + carer = carers[i] + search(i + 1, used, pairs) # this carer cares for no one in the unit + for t in targets: + if t != carer and t not in used: + search(i + 1, used | {t}, pairs + [(carer, t)]) + + search(0, frozenset(), []) + cp_count, total = best_key + return cp_count, len(carers) - total + + +def _feasible(carer_units, demand): + """Can each unit receive demand[unit] carers, one person each, from carers + who are not in that unit? Bipartite matching (augmenting paths).""" + slots = [u for u, n in demand.items() for _ in range(n)] + match = {} + + def assign(c, seen): + for s_i, u in enumerate(slots): + if s_i in seen or carer_units[c] == u: + continue + seen.add(s_i) + if s_i not in match or assign(match[s_i], seen): + match[s_i] = c + return True + return False + + return sum(assign(c, set()) for c in range(len(carer_units))) == len(slots) def household_carer_assignments(household: Mapping[str, Any]) -> dict: @@ -195,16 +246,17 @@ def household_carer_assignments(household: Mapping[str, Any]) -> dict: person shall be entitled for the same day to (a) more than one allowance under this section", so each award is for one person. - Rule 3 [as amended]: - * Nobody cares for themselves. - * A claimant/partner who receives a carer benefit cares for their partner if - the partner qualifies. Any other member of a unit who receives one cares - for a qualifying claimant/partner of that unit if there is one. - * Every remaining carer in the household cares for a qualifying - claimant/partner of another unit who is not yet cared for, one each. The - units take them in turn, eldest member first (ties by listing order); - each unit takes as many as it has uncared-for qualifiers, from carers - outside it, while any remain. + Rule 3 [as amended; person-to-person matching, not the implementation's + counting]: + * Nobody cares for themselves, and each person has at most one carer. + * Within their own unit, carers care for qualifying claimants/partners + first and then other qualifying members (such as a disabled child), + as many as possible (``_best_within_unit``). + * Every carer left over cares for a qualifying claimant/partner of + another unit who is not yet cared for. The units, eldest member first + (ties by the listing order of their eldest member), each take the most + such carers that can still be placed without taking any from an + earlier unit (``_feasible``). Rule 3 does not say which partner a carer takes when both qualify and neither is yet cared for. This returns every allowed set, and @@ -213,20 +265,14 @@ def household_carer_assignments(household: Mapping[str, Any]) -> dict: people = household["people"] units = household["benunits"] position = {pid: i for i, pid in enumerate(people)} - cared_by_partner, uncovered, qualifying, outside = {}, {}, {}, {} + qualifying, cared_within, carer_units = {}, {}, [] for bu_id, unit in units.items(): - cp = list(unit["claimant_or_partner"]) - qualifying[bu_id] = [pid for pid in cp if qualifies(people[pid])] - carers = [p for p in _members(unit) if people[p]["receives_carer_benefit"]] - within = [p for p in carers if _cares_within_own_unit(people, unit, p)] - outside[bu_id] = len(carers) - len(within) - # A claimant/partner caring within the unit cares for their partner. - cared_by_partner[bu_id] = { - other for p in within if p in cp for other in cp if other != p - } - by_others = len([p for p in within if p not in cp]) - covered = min(len(qualifying[bu_id]), len(cared_by_partner[bu_id]) + by_others) - uncovered[bu_id] = len(qualifying[bu_id]) - covered + qualifying[bu_id] = [ + p for p in unit["claimant_or_partner"] if qualifies(people[p]) + ] + cp_count, left_over = _best_within_unit(people, unit) + cared_within[bu_id] = cp_count + carer_units += [bu_id] * left_over def eldest_key(bu_id): members = _members(units[bu_id]) @@ -234,21 +280,29 @@ def eldest_key(bu_id): first = min(position[p] for p in members if people[p]["age"] == oldest) return (-oldest, first) - total_outside = sum(outside.values()) - allocated, from_other_units = 0, {} + from_other_units = {} for bu_id in sorted(units, key=eldest_key): - available = max( - 0, min(total_outside - outside[bu_id], total_outside - allocated) - ) - from_other_units[bu_id] = min(uncovered[bu_id], available) - allocated += from_other_units[bu_id] + needed = len(qualifying[bu_id]) - cared_within[bu_id] + for k in range(needed, -1, -1): + if _feasible(carer_units, {**from_other_units, bu_id: k}): + from_other_units[bu_id] = k + break result = {} - for bu_id in units: - cared_count = ( - len(qualifying[bu_id]) - uncovered[bu_id] + from_other_units[bu_id] + for bu_id, unit in units.items(): + cared_count = cared_within[bu_id] + from_other_units[bu_id] + cp = list(unit["claimant_or_partner"]) + # A claimant/partner caring for a qualifying partner fixes who is cared + # for; otherwise any of the qualifying claimant/partners may be. + must = frozenset( + other + for p in cp + if people[p]["receives_carer_benefit"] + for other in cp + if other != p and qualifies(people[other]) ) - must = frozenset(cared_by_partner[bu_id]) + if len(must) > cared_count: + must = frozenset() rest = [q for q in qualifying[bu_id] if q not in must] result[bu_id] = [ must | frozenset(choice) diff --git a/policyengine_uk/tests/test_severe_disability_addition_properties.py b/policyengine_uk/tests/test_severe_disability_addition_properties.py index 8886e9241a..c03142839b 100644 --- a/policyengine_uk/tests/test_severe_disability_addition_properties.py +++ b/policyengine_uk/tests/test_severe_disability_addition_properties.py @@ -2,10 +2,10 @@ State Pension Credit Regulations 2002 reg 6(4)-(5) and Sch I paras 1-2, as encoded in severe_disability_minimum_guarantee_addition and the variables it -reads. Households are generated with a pension-age benefit unit (single or -couple, possibly with dependants) and up to two other benefit units, each -person drawn with a disability benefit (or none), blindness, a carer benefit -and, for 16- to 19-year-olds, non-advanced education. is_claimant_or_partner is +reads. Households are generated with a pension-age benefit unit and up to two +other benefit units, each single or a couple and possibly with dependants, +each person drawn with a disability benefit (or none), blindness, a carer +benefit and, for 16- to 19-year-olds, non-advanced education. is_claimant_or_partner is set from the generated roles, as the survey data supply it. Invariants, for every benefit unit in every generated household: @@ -77,7 +77,10 @@ def household(draw): dependants=draw(st.lists(person(0, 19), max_size=2)), ) others = [ - dict(claimants=[draw(person(16, 90))], dependants=[]) + dict( + claimants=[draw(person(18, 90)) for _ in range(draw(st.integers(1, 2)))], + dependants=draw(st.lists(person(0, 19), max_size=1)), + ) for _ in range(draw(st.integers(0, 2))) ] return [focal] + others diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py index e13ca7048a..ad8ad3d908 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py @@ -13,15 +13,18 @@ class num_severe_disability_addition_qualifiers_cared_for(Variable): "Carer's Allowance or Carer Support Payment in respect of caring for " "them. The data do not say whom a carer cares for. A carer benefit " "needs the person cared for to receive a qualifying disability " - "benefit, and each award is for one person (SSCBA 1992 s.70(7)), so " - "each carer is attributed as follows. A claimant or partner cares for " - "their partner if the partner qualifies; any other member of a benefit " - "unit cares for a qualifying claimant or partner of that unit if there " - "is one. No one is their own carer. The household's other carers are " - "allocated to qualifying claimants or partners not yet cared for in " - "the other benefit units, one each, taking the units in descending " - "order of their eldest member's age. Carers outside the household, and " - "Universal Credit awards that include the carer element, are not " + "benefit, and each award is for one person, who has only one such " + "carer (SSCBA 1992 s.70(7) and (7ZA)), so each carer is attributed as " + "follows. No one is their own carer. A carer cares for someone in " + "their own benefit unit who receives a qualifying benefit if there is " + "one: a qualifying claimant or partner first, then another member such " + "as a disabled child, each such person having one carer. Every other " + "carer in the household, including one left over in their own unit, " + "is allocated to a qualifying claimant or partner not yet cared for in " + "another benefit unit, one each, taking the units in descending order " + "of their eldest member's age (ties in the order the units' eldest " + "members are listed in the household). Carers outside the household, " + "and Universal Credit awards that include the carer element, are not " "counted. Reading Universal Credit here would be circular, because the " "Universal Credit non-dependant deduction exemption reads Pension " "Credit; a Universal Credit carer in the household already stops the " @@ -41,21 +44,37 @@ class num_severe_disability_addition_qualifiers_cared_for(Variable): def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) - qualifies = claimant_or_partner & person( + receives = person( "receives_severe_disability_addition_qualifying_benefit", period ) + qualifies = claimant_or_partner & receives + dependant_receives = ~claimant_or_partner & receives carer = person("receives_carer_benefit", period) qualifying = benunit.sum(qualifies) - # Carers within their own unit: there is a qualifying claimant or - # partner in the carer's unit other than the carer. + receiving_dependants = benunit.sum(dependant_receives) + # People in the carer's own unit the carer could be caring for. qualifiers_in_own_unit = benunit.project(qualifying) - qualifies - cares_within_unit = carer & (qualifiers_in_own_unit > 0) - cared_for_within = min_(qualifying, benunit.sum(cares_within_unit)) + dependants_in_own_unit = ( + benunit.project(receiving_dependants) - dependant_receives + ) + cares_for_a_qualifier = carer & (qualifiers_in_own_unit > 0) + cares_within_unit = carer & ( + (qualifiers_in_own_unit + dependants_in_own_unit) > 0 + ) + # Qualifying claimants and partners first, one carer each. + cared_for_within = min_(qualifying, benunit.sum(cares_for_a_qualifier)) not_yet_cared_for = qualifying - cared_for_within + placed_within = min_( + benunit.sum(cares_within_unit), qualifying + receiving_dependants + ) # Every other carer benefit is for someone in another unit. - cares_outside_unit = carer & ~cares_within_unit - outside_here = benunit.sum(cares_outside_unit) - outside_in_household = benunit.max(person.household.sum(cares_outside_unit)) + outside_here = benunit.sum(carer) - placed_within + outside_members = where( + person.get_rank(person.benunit, -person("age", period)) == 0, + benunit.project(outside_here), + 0, + ) + outside_in_household = benunit.max(person.household.sum(outside_members)) # Order the household's units by their eldest member (ties by position) # and give each unit, in turn, carers from the other units. age = person("age", 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 1721010ad1..9e8d299a53 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 @@ -62,7 +62,7 @@ def formula(benunit, period, parameters): # rate. Para 1(1)(a): one rate. couple_rates = where( qualifying >= 2, - 2 - cared_for, + max_(2 - cared_for, 0), (qualifying == 1) & other_partner_blind & (cared_for == 0), ) single_rates = (qualifying >= 1) & (cared_for == 0) From 228de587eb30b7b5d31ff2a73acee875fba29746 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:18:58 -0400 Subject: [PATCH 6/6] Pin carer self-exclusion for a qualifying dependant; state the tie order as person order Tests: a qualifying 17-year-old carer with no one else to care for in her own unit cares for the grandmother; two young carers in their own units cover both pensioners. Documentation: ties between units go by person order; the reference's within-unit helper returns two values. Co-Authored-By: Claude Opus 5.5 --- ...evere_disability_residence_and_carers.yaml | 76 ++++++++++++++++++- .../severe_disability_addition_reference.py | 18 ++--- ...isability_addition_qualifiers_cared_for.py | 6 +- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml index 89ccd6823d..75e75aafbd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -810,7 +810,7 @@ - name: A second carer in a pensioner's unit is attributed to a pensioner in another unit # Each person has only one carer (SSCBA 1992 s.70(7ZA)), so the second of # two young carers in the first pensioner's unit cares for the second - # pensioner. The result is the same whichever unit the carers are in. + # pensioner, as when the carers are in units of their own (next case). period: 2026 absolute_error_margin: 0 input: @@ -842,7 +842,7 @@ num_severe_disability_addition_qualifiers_cared_for: [1, 1] severe_disability_minimum_guarantee_addition: [0, 0] -- name: When two benefit units' eldest members are the same age, the one listed first takes a carer +- name: When two benefit units' eldest members are the same age, the one whose eldest member comes first in person order takes a carer period: 2026 absolute_error_margin: 0.01 input: @@ -871,3 +871,75 @@ output: num_severe_disability_addition_qualifiers_cared_for: [1, 0, 0] severe_disability_minimum_guarantee_addition: [0, 4_474.60, 0] + +- name: A carer who is the only qualifying member of their own unit cares for someone in another unit + # No one is their own carer. The 17-year-old receives the DLA care + # component at the middle rate and Carer's Allowance; her blind father + # (ignored as a resident) receives no qualifying benefit, so she has no one + # to care for in her own unit and is attributed to the grandmother. + period: 2026 + absolute_error_margin: 0 + input: + people: + grandmother: + age: 80 + aa_category: HIGHER + father: + age: 45 + is_parent: true + is_blind: true + daughter: + age: 17 + dla_sc_category: MIDDLE + care_hours: 35 + carers_allowance_reported: 1 + benunits: + grandmother_unit: + members: [grandmother] + father_unit: + members: [father, daughter] + households: + household: + members: [grandmother, father, daughter] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 0] + severe_disability_minimum_guarantee_addition: [0, 0] + +- name: The two young carers cover both pensioners when each is in their own unit too + # The same people as the case with both carers in the first pensioner's + # unit, with the carers now in benefit units of their own. + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 80 + aa_category: HIGHER + carer_1: + age: 17 + care_hours: 35 + carers_allowance_reported: 1 + carer_2: + age: 16 + care_hours: 35 + carers_allowance_reported: 1 + second: + age: 78 + aa_category: HIGHER + benunits: + first_unit: + members: [first] + carer_unit_1: + members: [carer_1] + carer_unit_2: + members: [carer_2] + second_unit: + members: [second] + households: + household: + members: [first, carer_1, carer_2, second] + country: ENGLAND + output: + num_severe_disability_addition_qualifiers_cared_for: [1, 0, 0, 1] + severe_disability_minimum_guarantee_addition: [0, 0, 0, 0] diff --git a/policyengine_uk/tests/severe_disability_addition_reference.py b/policyengine_uk/tests/severe_disability_addition_reference.py index 07273f664d..69728dcaa1 100644 --- a/policyengine_uk/tests/severe_disability_addition_reference.py +++ b/policyengine_uk/tests/severe_disability_addition_reference.py @@ -186,24 +186,22 @@ def _best_within_unit(people, unit): qualifying benefit, never themselves; each such person has at most one carer. Among all such matchings, take one that cares for the most qualifying claimants/partners, and then the most people. Enumerated by - brute force (units are small). Returns (claimant/partners who must be cared - for in every best matching, the number cared for, carers left unmatched). + brute force (units are small). Returns (the number of qualifying + claimant/partners cared for, the number of carers left unmatched). """ cp = list(unit["claimant_or_partner"]) members = _members(unit) carers = [p for p in members if people[p]["receives_carer_benefit"]] targets = [p for p in members if qualifies(people[p])] - best_key, best = None, [] + best_key = None def search(i, used, pairs): - nonlocal best_key, best + nonlocal best_key if i == len(carers): covered = {t for _, t in pairs} key = (len(covered & set(cp)), len(covered)) if best_key is None or key > best_key: - best_key, best = key, [covered] - elif key == best_key: - best.append(covered) + best_key = key return carer = carers[i] search(i + 1, used, pairs) # this carer cares for no one in the unit @@ -254,9 +252,9 @@ def household_carer_assignments(household: Mapping[str, Any]) -> dict: as many as possible (``_best_within_unit``). * Every carer left over cares for a qualifying claimant/partner of another unit who is not yet cared for. The units, eldest member first - (ties by the listing order of their eldest member), each take the most - such carers that can still be placed without taking any from an - earlier unit (``_feasible``). + (ties in person order, the order people are listed in the input), + each take the most such carers that can still be placed without + taking any from an earlier unit (``_feasible``). Rule 3 does not say which partner a carer takes when both qualify and neither is yet cared for. This returns every allowed set, and diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py index ad8ad3d908..da7e25d637 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py @@ -22,8 +22,8 @@ class num_severe_disability_addition_qualifiers_cared_for(Variable): "carer in the household, including one left over in their own unit, " "is allocated to a qualifying claimant or partner not yet cared for in " "another benefit unit, one each, taking the units in descending order " - "of their eldest member's age (ties in the order the units' eldest " - "members are listed in the household). Carers outside the household, " + "of their eldest member's age (ties in person order, the order people " + "are listed in the input or dataset). Carers outside the household, " "and Universal Credit awards that include the carer element, are not " "counted. Reading Universal Credit here would be circular, because the " "Universal Credit non-dependant deduction exemption reads Pension " @@ -75,7 +75,7 @@ def formula(benunit, period, parameters): 0, ) outside_in_household = benunit.max(person.household.sum(outside_members)) - # Order the household's units by their eldest member (ties by position) + # Order the household's units by their eldest member (ties by person order) # and give each unit, in turn, carers from the other units. age = person("age", period) representative = person.get_rank(person.benunit, -age) == 0