diff --git a/changelog.d/is-eligibility-claimant-partner-gates.fixed.md b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md new file mode 100644 index 0000000000..5a7105f772 --- /dev/null +++ b/changelog.d/is-eligibility-claimant-partner-gates.fixed.md @@ -0,0 +1,5 @@ +- Apply the Income Support ESA and existing-award conditions only to the claimant and partner (SSCBA 1992 s.124(1)). Another member of the benefit unit who is neither the claimant nor the partner (a non-dependent adult or a dependent young person) no longer bars the claim by having income-related ESA, and no longer counts as having an existing Income Support award. +- Treat the claimant or partner who reports the existing Income Support award as the claimant. No new claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1), from 25 July 2022; the model applies this in every year), and a partner who takes over an award makes a claim (Claims and Payments Regs 1987 reg 4(4)). That person must be under the qualifying age for State Pension Credit, in a prescribed category and without ESA, so a partner who cares can no longer take over the other partner's award. +- Bar Income Support when the claimant is entitled to contributory ESA (s.124(1)(h)). Income-related ESA of the claimant or partner still bars it. An `esa_income` that the reported awards do not explain (one entered directly, or set by a reform) is taken to be theirs. An amount within half a penny of zero is no award, so a float residual no longer bars the claim. +- Add the IS Regs 1987 Sch 1B para 2 prescribed category: a single claimant or lone parent with a child under 16 placed by a local authority (proxied by `is_looked_after_by_local_authority`). +- Leave a child placed by a local authority out of `youngest_child_age_for_legacy_benefits`; such a child is not a member of the claimant's household (IS Regs 1987 reg 16(4)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml index 2cb2c87660..56bfe7707d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_carer_route.yaml @@ -6,7 +6,10 @@ # disability benefit, with no Carer's Allowance claim needed, or (b) "is both # entitled to, and in receipt of, a carer's allowance or carer support # payment". A couple choose which of them claims (Claims and Payments Regs -# 1987 reg 4(3)), so either partner's caring qualifies; a child's or young +# 1987 reg 4(3)), but no new claim can be made (UC (Transitional Provisions) +# Regs 2014 reg 6A(1)) and a partner who takes over an award does so by +# claiming (reg 4(4)), so the claimant is whichever partner has the existing +# award. The route opens through that partner's caring; a child's or young # person's caring does not. - name: IS carer route for a single claimant who cares @@ -44,16 +47,16 @@ is_carer_for_benefits: true income_support_eligible: true -- name: IS carer route when the partner is the carer +- name: IS carer route when the partner who has the award is the carer period: 2025 input: people: claimant: age: 40 - income_support_reported: 1000 partner: age: 38 receives_carer_benefit: true + income_support_reported: 1000 benunits: family: members: [claimant, partner] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml new file mode 100644 index 0000000000..1dc8fa0c6b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_claimant_partner_gates.yaml @@ -0,0 +1,635 @@ +# Income Support conditions name the claimant and, for some, the partner. +# SSCBA 1992 s.124(1): a person is entitled to Income Support if +# (aa) "he has not attained the qualifying age for state pension credit"; +# (e) "he falls within a prescribed category of person" (reg 4ZA, Sch 1B; +# the model covers para 1, a lone parent of a child under 5; para 2, +# a single claimant or lone parent with whom a child is placed; and +# para 4, a carer); +# (g) "if he is a member of a couple, the other member of the couple is not +# entitled to state pension credit"; and +# (h) "he is not entitled to an employment and support allowance and, if he +# is a member of a couple, the other member of the couple is not entitled +# to an income-related employment and support allowance". +# No new claim for Income Support can be made (UC (Transitional Provisions) +# Regs 2014 reg 6A(1)). A couple choose which of them claims (Claims and +# Payments Regs 1987 reg 4(3)), but a partner who takes over an award does so +# by claiming (reg 4(4)), so the claimant is the partner with the existing +# award. The model takes that to be the claimant or partner who reports +# Income Support. +# +# An adult who is neither the claimant nor the partner (here given +# is_claimant_or_partner: false) is named by none of these conditions, so it +# never changes eligibility. A child placed by a local authority is not a +# member of the claimant's household (IS Regs 1987 reg 16(4)), so it is not a +# young child for para 1, but it brings a single claimant within para 2. +# These cases test the model's bounded gate: +# caring is is_carer_for_benefits, ESA and Income Support are reported +# awards, and the means test is out of scope. 2025 is used because the model +# pays Income Support until 2025-26; state pension age is 66 in 2025. + +- name: An excluded member over state pension age does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 70 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + is_SP_age: [false, true] + income_support_eligible: true + +- name: An excluded member's income-related ESA does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_income_reported: 5_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + # The benefit unit's reported award is still paid; it is the excluded + # member's own award, not the claimant's or partner's. + esa_income: 5_000 + income_support_eligible: true + +- name: An excluded member's contributory ESA does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_contrib_reported: 5_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +- name: An excluded member's reported Income Support is not the claimant's award + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + other_adult: + age: 30 + is_claimant_or_partner: false + income_support_reported: 1_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: An excluded member's caring does not open the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + receives_carer_benefit: true + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + is_carer_for_benefits: [false, true] + income_support_eligible: false + +- name: A lone parent's route is unchanged by an excluded adult in the benefit unit + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + income_support_reported: 1_000 + child: + age: 3 + is_claimant_or_partner: false + grandparent: + age: 72 + is_claimant_or_partner: false + esa_income_reported: 2_000 + esa_contrib_reported: 1_000 + benunits: + family: + members: [parent, child, grandparent] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + is_lone_parent: true + youngest_child_age_for_legacy_benefits: 3 + income_support_eligible: true + +- name: A carer cannot take over the award of a partner who does not care + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + partner: + age: 38 + is_claimant_or_partner: true + income_support_reported: 1_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + # The partner has the award but is in no prescribed category; the carer + # could only claim, and reg 6A(1) bars a new claim. + income_support_eligible: false + +- name: A child placed with a lone parent brings them within Sch 1B para 2, not para 1 + period: 2025 + input: + people: + parent: + age: 40 + is_claimant_or_partner: true + is_parent: true + income_support_reported: 1_000 + child: + age: 8 + is_claimant_or_partner: false + placed_child: + age: 3 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [parent, child, placed_child] + income_support_assessable_capital: 0 + output: + # IS Regs 1987 reg 16(4): a child placed by a local authority is not a + # member of the claimant's household, so the youngest child is 8 and + # para 1 does not apply. Para 2: "A single claimant or a lone parent with + # whom a child is placed" by a local authority. + is_child_or_young_person_for_legacy_benefits: [false, true, false] + youngest_child_age_for_legacy_benefits: 8 + income_support_eligible: true + +- name: A single claimant with only a placed child is within Sch 1B para 2 + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed_child: + age: 10 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [claimant, placed_child] + income_support_assessable_capital: 0 + output: + is_lone_parent: false + income_support_eligible: true + +- name: A placed member aged 15 is a child for Sch 1B para 2, aged 16 is not + period: 2025 + input: + people: + claimant1: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed15: + age: 15 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + claimant2: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + placed16: + age: 16 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family1: + members: [claimant1, placed15] + income_support_assessable_capital: 0 + family2: + members: [claimant2, placed16] + income_support_assessable_capital: 0 + output: + # A child is a person under 16 (SSCBA 1992 s.137(1)). + income_support_eligible: [true, false] + +- name: A couple with a placed child are not within Sch 1B para 2 + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + income_support_reported: 1_000 + partner: + age: 38 + is_claimant_or_partner: true + placed_child: + age: 3 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + family: + members: [claimant, partner, placed_child] + income_support_assessable_capital: 0 + output: + # Para 2 needs a single claimant or lone parent; neither partner cares. + income_support_eligible: false + +# s.124(1)(aa) and (g): the claimant's age, and the partner's SPC entitlement. +# The model takes a couple to be on State Pension Credit where it meets the +# Pension Credit age conditions (meets_pension_credit_age_conditions) and +# would claim it (would_claim_pc). + +- name: A mixed-age couple can claim through the younger partner who cares + period: 2025 + input: + people: + carer: + age: 60 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 70 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + is_SP_age: [false, true] + # SPCA 2002 s.4(1A): a member of a mixed-age couple is not entitled to + # State Pension Credit, so s.124(1)(g) does not bar the younger partner. + # Neither reports Pension Credit, so the couple has no SI 2019/37 art. 4 + # saving. + is_pension_credit_eligible: false + income_support_eligible: true + +- name: The younger partner's award is barred while the couple keeps Pension Credit under the SI 2019/37 saving + period: 2025 + input: + people: + carer: + age: 60 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + # Born before 6 February 1954, so over the qualifying age on + # 14 May 2019 (SI 2019/37 art. 4(1)). + age: 75 + is_claimant_or_partner: true + pension_credit_reported: 1_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + has_mixed_age_couple_pension_credit_saving: true + meets_pension_credit_age_conditions: true + # s.124(1)(g): the other member of the couple is entitled to State + # Pension Credit, so the carer who has the award cannot claim. + income_support_eligible: false + +- name: The same couple keeps Income Support when the take-up flag says it would not claim Pension Credit + period: 2025 + input: + people: + carer: + age: 60 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 75 + is_claimant_or_partner: true + pension_credit_reported: 1_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + # The model reads (g) through the take-up flag. The reported Pension + # Credit gives the couple the SI 2019/37 saving + # (meets_pension_credit_age_conditions), not entitlement. + would_claim_pc: false + output: + meets_pension_credit_age_conditions: true + income_support_eligible: true + +- name: An excluded member's Pension Credit does not bar the carer route + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 75 + is_claimant_or_partner: false + pension_credit_reported: 1_000 + benunits: + family: + members: [claimant, other_adult] + income_support_assessable_capital: 0 + output: + # The single claimant is not a member of a couple, so s.124(1)(g) does + # not apply and the other adult's Pension Credit is not the family's. + is_mixed_age_couple: false + meets_pension_credit_age_conditions: false + income_support_eligible: true + +- name: A younger carer cannot take over an older partner's award + period: 2025 + input: + people: + holder: + age: 67 + is_claimant_or_partner: true + income_support_reported: 1_000 + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + benunits: + family: + members: [holder, carer] + income_support_assessable_capital: 0 + output: + # The award holder is over state pension age and not a carer; the carer + # would have to make a new claim (reg 4(4)), which reg 6A(1) bars. + income_support_eligible: false + +- name: A carer over state pension age cannot claim, and the younger partner is not a carer + period: 2025 + input: + people: + carer: + age: 70 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 60 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: A single carer over state pension age cannot claim + period: 2025 + input: + people: + carer: + age: 70 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +# s.124(1)(h): the claimant has no ESA of either kind; the partner has no +# income-related ESA. + +- name: A single carer entitled to contributory ESA cannot claim + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_contrib_reported: 3_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: The carer can claim when only the partner has contributory ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_contrib_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: true + +- name: Neither partner can claim when the carer has contributory ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_contrib_reported: 3_000 + partner: + age: 42 + is_claimant_or_partner: true + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + output: + income_support_eligible: false + +- name: The partner's income-related ESA bars the carer route + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + esa_income: 3_000 + income_support_eligible: false + +- name: A reported award the ESA capital test screens out does not bar the carer route + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + partner: + age: 42 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + family: + members: [carer, partner] + income_support_assessable_capital: 0 + # Above the £16,000 ESA capital limit (ESA Regs 2008 reg 110). + esa_income_assessable_capital: 20_000 + output: + esa_income: 0 + income_support_eligible: true + +- name: An award entered directly bars the claim even when an excluded member reports ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + other_adult: + age: 30 + is_claimant_or_partner: false + esa_income_reported: 5_000 + benunits: + family: + members: [carer, other_adult] + income_support_assessable_capital: 0 + esa_income: 3_000 + output: + income_support_eligible: false + +- name: A zero award entered directly overrides the claimant's reported ESA + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + esa_income_reported: 2_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + esa_income: 0 + output: + # The model pays no income-related ESA, so none bars Income Support. + income_support_eligible: true + +- name: An income-related ESA award entered directly is the claimant's or partner's + period: 2025 + input: + people: + carer: + age: 40 + is_claimant_or_partner: true + receives_carer_benefit: true + income_support_reported: 1_000 + benunits: + family: + members: [carer] + income_support_assessable_capital: 0 + esa_income: 3_000 + output: + income_support_eligible: false + +# Roles inferred rather than given: a couple flagged as parents, their child +# and a grandparent. The grandparent is neither claimant nor partner +# (is_claimant_or_partner), so their age, ESA and caring do not count. + +- name: An inferred grandparent does not change a couple's carer route + period: 2025 + input: + people: + mother: + age: 34 + is_parent: true + is_benunit_head: true + receives_carer_benefit: true + income_support_reported: 1_000 + father: + age: 36 + is_parent: true + child: + age: 8 + grandparent: + age: 71 + esa_income_reported: 2_000 + benunits: + family: + members: [mother, father, child, grandparent] + income_support_assessable_capital: 0 + esa_income_assessable_capital: 0 + output: + is_claimant_or_partner: [true, true, false, false] + income_support_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml index ddbdc9255a..e0d14375c4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml @@ -485,11 +485,12 @@ pension_credit_assessable_capital: 0 - name: Case 21, a mixed-age couple can claim Income Support through the younger partner. - # SSCBA 1992 s.124(1)(aa) restricts the claimant, and either member of a - # couple can claim. She is a carer under the qualifying age with an - # existing award; he was born 6 June 1953 and reached the qualifying age on - # 6 March 2017, though his own pensionable age is 6 June 2018. They have not - # claimed Pension Credit. + # SSCBA 1992 s.124(1)(aa) restricts the claimant. The model takes the + # claimant to be the member of the couple with the existing award (see + # income_support_eligible). She is a carer under the qualifying age with + # that award; he was born 6 June 1953 and reached the qualifying age on + # 6 March 2017, though his own pensionable age is 6 June 2018. They have + # not claimed Pension Credit. period: 2017 input: people: diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py new file mode 100644 index 0000000000..271d20e827 --- /dev/null +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -0,0 +1,426 @@ +"""Income Support eligibility follows only the claimant and partner. + +SSCBA 1992 s.124(1) sets each condition for the claimant ("he") and, in +paras (c), (f), (g) and (h), for "the other member of the couple". No new +claim can be made (UC (Transitional Provisions) Regs 2014 reg 6A(1)) and a +partner who takes over an award does so by claiming (Claims and Payments +Regs 1987 reg 4(4)), so the claimant is the partner with the existing award. +No adult outside the couple is named, so: + +- adding an adult who is neither the claimant, the partner nor a young person + in the family never changes income_support_eligible, whatever that adult's + age, ESA, Income Support or caring. The declared exception, outside the + values drawn here, is an esa_income entered directly that the new adult's + report makes equal to what the reports give: it is then read through the + reports (test_income_support_esa_entered_directly.py); +- income_support_eligible equals a family-by-family reading of the model's + gate: one of the claimant and partner reports Income Support, is under + the qualifying age for State Pension Credit, is in a prescribed category + the model covers (a carer; a lone parent of a child aged 5 or under, the + model's reading of Sch 1B para 1, counting only children in the + household, reg 16(4); or a single claimant with a child placed by a local + authority, para 2) and has no contributory ESA + (s.124(1)(aa), (e), (h)); neither has income-related ESA (s.124(1)(h)), + meaning the award on their reported amounts after the ESA capital test, or + an esa_income the reported amounts do not explain (one that equals neither + the award on everyone's reported amounts nor their plain total); the + couple is not taken to be on State Pension Credit (s.124(1)(g)); and + capital is within the Income Support limit. + +The second property is a reference check of the bounded model gate, not of +legal entitlement: caring, ESA and Income Support are the model's reported +or proxy inputs; it reads the qualifying age +(has_attained_state_pension_credit_qualifying_age) and the model's proxy for +being on Pension Credit (meets_pension_credit_age_conditions and +would_claim_pc) from the model; and the means test is out of scope. No +claimant or partner drawn here reports Pension Credit or Housing Benefit +(only some added adults do), so in 2025 no family with a claimant or partner +under the qualifying age meets the Pension Credit age conditions. The +differential asserts this, and income_support_claimant_partner_gates.yaml +pins (g). + +Roles are given explicitly (is_claimant_or_partner), so the properties test +the eligibility rule rather than the role inference; the inferred case is +covered in income_support_claimant_partner_gates.yaml. Each example builds +many families in one simulation, in separate households and benefit units. +Capital is either entered as each benefit unit's assessable capital or as +household savings, which both capital tests read. +""" + +import math + +import numpy as np +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 + + +@st.composite +def adult_inputs(draw, min_age=18): + return { + "age": draw(st.integers(min_age, 90)), + "receives_carer_benefit": draw(st.booleans()), + "care_hours": draw(st.sampled_from([0, 34, 35])), + "esa_income_reported": draw(st.sampled_from([0, 0, 200, 3_000])), + "esa_contrib_reported": draw(st.sampled_from([0, 0, 3_000])), + "income_support_reported": draw(st.sampled_from([0, 1_000, 1_000])), + } + + +PRIMED_CLAIMANT = { + "income_support_reported": 1_000, + "esa_income_reported": 0, + "esa_contrib_reported": 0, +} + + +@st.composite +def families(draw): + """A claimant, an optional partner, up to three dependants and capital. + + Some families are drawn at random. The rest are primed to sit at the + edge of eligibility: + + - carer: a claimant who reports Income Support and cares; + - lone_parent: a lone parent who reports it, does not care and has only + children over 5; + - split_couple: one partner reports Income Support but fails a + condition, and the other qualifies but has no award, so the carer + cannot take the award over. + + A quarter of families also have a child placed by a local authority. + """ + shape = draw( + st.sampled_from(["random", "random", "carer", "lone_parent", "split_couple"]) + ) + n_dependants = draw(st.integers(1 if shape == "lone_parent" else 0, 3)) + dependants = [] + for _ in range(n_dependants): + if shape == "lone_parent": + dependant = {"age": draw(st.integers(6, 15))} + elif draw(st.booleans()): + dependant = {"age": draw(st.integers(0, 15))} + else: + # A qualifying young person: 16-19 in non-advanced education. + dependant = { + "age": draw(st.integers(16, 19)), + "current_education": "UPPER_SECONDARY", + } + dependants.append(dependant) + eldest_dependant = max([d["age"] for d in dependants], default=0) + adults = [draw(adult_inputs(min_age=max(18, eldest_dependant + 16)))] + if shape == "split_couple" or (shape != "lone_parent" and draw(st.booleans())): + adults.append(draw(adult_inputs())) + if shape == "split_couple": + failure = draw(st.sampled_from(["over_qualifying_age", "no_category", "esa"])) + adults[0].update( + PRIMED_CLAIMANT, + age=draw(st.integers(max(adults[0]["age"], 66), 90)) + if failure == "over_qualifying_age" + else min(adults[0]["age"], 65), + receives_carer_benefit=failure != "no_category", + care_hours=0, + esa_contrib_reported=3_000 if failure == "esa" else 0, + ) + adults[1].update( + age=min(adults[1]["age"], 65), + receives_carer_benefit=True, + income_support_reported=0, + esa_income_reported=0, + esa_contrib_reported=0, + ) + elif shape == "carer": + adults[0].update( + PRIMED_CLAIMANT, age=min(adults[0]["age"], 65), receives_carer_benefit=True + ) + elif shape == "lone_parent": + adults[0].update( + PRIMED_CLAIMANT, + age=min(adults[0]["age"], 65), + receives_carer_benefit=False, + care_hours=0, + ) + if draw(st.integers(0, 3)) == 0: + dependants.append( + { + # Up to 17: para 2 covers only a placed child under 16. + "age": draw(st.integers(0, 17)), + "is_looked_after_by_local_authority": True, + } + ) + for adult in adults: + adult["is_parent"] = n_dependants > 0 + capital = draw(st.sampled_from([0, 6_250, 10_000, 20_000])) + return adults, dependants, capital + + +@st.composite +def excluded_members(draw): + """An adult who is neither claimant, partner nor in the family. + + They are not in education, so a 16 to 19 year old is not a qualifying + young person. Most are primed with what barred or opened the claim when + every member counted: over state pension age, income-related ESA, an + Income Support report, or caring. Some are primed with what the + s.124(1)(g) proxy reads for a couple: over the qualifying age, with + reported Pension Credit or Housing Benefit. + """ + adult = { + **draw(adult_inputs(min_age=16)), + "current_education": "NOT_IN_EDUCATION", + } + primed = draw( + st.sampled_from(["random", "elderly", "esa", "award", "carer", "pension"]) + ) + if primed == "elderly": + adult["age"] = draw(st.integers(66, 90)) + elif primed == "esa": + adult["esa_income_reported"] = 3_000 + elif primed == "award": + adult["income_support_reported"] = 1_000 + elif primed == "carer": + adult["receives_carer_benefit"] = True + elif primed == "pension": + adult["age"] = draw(st.integers(66, 90)) + reported = draw( + st.sampled_from(["pension_credit_reported", "housing_benefit_reported"]) + ) + adult[reported] = 1_000 + return adult + + +@st.composite +def input_settings(draw, n): + """How capital and income-related ESA are entered for n families. + + Capital goes in as assessable capital or as household savings. When + esa_income is entered directly, it is entered for every family, so it is + a simulation input and the formula does not run. + """ + capital_as_savings = draw(st.booleans()) + # £4,000 is neither a total of the reported amounts drawn here nor such a + # total less tariff income, so it is always read as entered directly. + esa_income = ( + draw(st.lists(st.sampled_from([0, 4_000, 4_000]), min_size=n, max_size=n)) + if draw(st.booleans()) + else None + ) + return capital_as_savings, esa_income + + +def label(units, capital_as_savings, esa_income): + """Record which input surfaces an example reaches (--hypothesis-show-statistics).""" + event( + "capital as household savings" if capital_as_savings else "assessable capital" + ) + event("esa_income entered directly" if esa_income else "esa_income calculated") + extras = [extra for *_, extra in units if extra is not None] + if any( + d.get("is_looked_after_by_local_authority") + for _, dependants, *_ in units + for d in dependants + ): + event("family with a placed child") + if any( + e.get("income_support_reported") or e.get("esa_income_reported") for e in extras + ): + event("added member reports IS or ESA") + + +def situation(units, capital_as_savings, esa_income): + label(units, capital_as_savings, esa_income) + people, benunits, households = {}, {}, {} + for i, (adults, dependants, capital, extra) in enumerate(units): + members = [(m, True) for m in adults] + [(m, False) for m in dependants] + if extra is not None: + members.append((extra, False)) + names = [] + for j, (inputs, claimant_or_partner) in enumerate(members): + name = f"p{i}_{j}" + people[name] = {k: {YEAR: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {YEAR: claimant_or_partner} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = {"members": names} + if capital_as_savings: + households[f"h{i}"]["savings"] = {YEAR: capital} + else: + benunits[f"b{i}"]["income_support_assessable_capital"] = {YEAR: capital} + benunits[f"b{i}"]["esa_income_assessable_capital"] = {YEAR: capital} + if esa_income is not None: + benunits[f"b{i}"]["esa_income"] = {YEAR: esa_income[i % len(esa_income)]} + return {"people": people, "benunits": benunits, "households": households} + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +# The invariance property needs rarer combinations (a placed young child in a +# lone parent's family, a direct esa_income beside an added member's ESA), so +# it runs more examples. +INVARIANCE_SETTINGS = settings(SETTINGS, max_examples=25) +# The differential property needs a directly entered award to meet an otherwise +# eligible family, so it runs as many. +DIFFERENTIAL_SETTINGS = settings(SETTINGS, max_examples=25) + +FAMILIES = st.lists(st.tuples(families(), excluded_members()), min_size=1, max_size=8) + + +@INVARIANCE_SETTINGS +@given(FAMILIES, st.data()) +def test_excluded_member_never_changes_is_eligibility(drawn, data): + capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) + without = [(*family, None) for family, _ in drawn] + with_extra = [(*family, extra) for family, extra in drawn] + sim = Simulation( + situation=situation(without + with_extra, capital_as_savings, esa_income) + ) + eligible = sim.calculate("income_support_eligible", YEAR) + if eligible.any(): + event("some family eligible") + flags = sim.calculate("is_claimant_or_partner", YEAR) + dependant = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) + sizes = [len(a) + len(d) + (e is not None) for a, d, _, e in without + with_extra] + ends = np.cumsum(sizes) + k = len(drawn) + for i in range(k): + # The added member is the last of its benefit unit: neither claimant, + # partner nor in the family. + last = ends[k + i] - 1 + assert not flags[last] and not dependant[last], drawn[i] + assert eligible[i] == eligible[k + i], drawn[i] + + +def reference_eligibility( + adults, + dependants, + capital, + esa_income, + attained_qualifying_age, + on_pension_credit, + parameters, +): + """The model's Income Support gate, read family by family.""" + IS = parameters.gov.dwp.income_support + ESA = parameters.gov.dwp.ESA.income.capital + # A child placed by a local authority is not a member of the household + # (reg 16(4)), so not a child for para 1, but brings a single claimant + # within para 2. + placed = [d for d in dependants if d.get("is_looked_after_by_local_authority")] + in_household = [d for d in dependants if d not in placed] + child_ages = [d["age"] for d in in_household if d["age"] < 16] + lone_parent_with_young_child = ( + len(adults) == 1 + and len(in_household) > 0 + and min(child_ages, default=math.inf) + <= IS.eligibility.lone_parent_youngest_child_age_limit + ) + # A child is under 16 (SSCBA s.137(1)). + single_with_placed_child = len(adults) == 1 and any(d["age"] < 16 for d in placed) + + def is_claimant(adult, over_qualifying_age): + carer = adult["receives_carer_benefit"] or adult["care_hours"] >= 35 + return ( + adult["income_support_reported"] > 0 + and (carer or lone_parent_with_young_child or single_with_placed_child) + and not over_qualifying_age + and adult["esa_contrib_reported"] == 0 + ) + + if esa_income is not None: + # Within half a penny of zero is no award. + income_related_esa = esa_income > 0.005 + else: + reported_esa = sum(a["esa_income_reported"] for a in adults) + tariff = ( + math.ceil( + max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + ) + * ESA.tariff_income.amount + * 52 + ) + income_related_esa = ( + reported_esa > 0 and capital <= ESA.limit and reported_esa > tariff + ) + return ( + any(is_claimant(a, s) for a, s in zip(adults, attained_qualifying_age)) + and not income_related_esa + # s.124(1)(g), read through the model's proxy for being on Pension + # Credit. + and not on_pension_credit + and capital <= IS.means_test.capital.limit + ) + + +def explained_by_reports(esa_income, reported_total, capital, parameters): + """Whether an esa_income equals what the reported amounts give: the + award after the capital test, or their plain total.""" + ESA = parameters.gov.dwp.ESA.income.capital + tariff = ( + math.ceil( + max(0, capital - ESA.tariff_income.threshold) / ESA.tariff_income.step + ) + * ESA.tariff_income.amount + * 52 + ) + award = ( + max(0, reported_total - tariff) + if reported_total > 0 and capital <= ESA.limit + else 0 + ) + return abs(esa_income - award) <= 0.005 or abs(esa_income - reported_total) <= 0.005 + + +@DIFFERENTIAL_SETTINGS +@given(FAMILIES, st.data()) +def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): + capital_as_savings, esa_income = data.draw(input_settings(len(drawn))) + units = [(*family, extra) for family, extra in drawn] + sim = Simulation(situation=situation(units, capital_as_savings, esa_income)) + eligible = sim.calculate("income_support_eligible", YEAR) + if eligible.any(): + event("some family eligible") + attained_qualifying_age = sim.calculate( + "has_attained_state_pension_credit_qualifying_age", YEAR + ) + on_pension_credit = sim.calculate( + "meets_pension_credit_age_conditions", YEAR + ) & sim.calculate("would_claim_pc", YEAR) + parameters = sim.tax_benefit_system.parameters(YEAR) + start = 0 + for i, (adults, dependants, capital, extra) in enumerate(units): + # An esa_income the reported amounts explain is read through them, + # like a calculated one. + entered = None + if esa_income is not None: + reported_total = sum( + member.get("esa_income_reported", 0) for member in adults + [extra] + ) + if not explained_by_reports( + esa_income[i], reported_total, capital, parameters + ): + entered = esa_income[i] + expected = reference_eligibility( + adults, + dependants, + capital, + entered, + attained_qualifying_age[start : start + len(adults)], + on_pension_credit[i], + parameters, + ) + assert eligible[i] == expected, units[i] + # The docstring's claim: no family drawn here with a claimant or + # partner under the qualifying age is taken to be on Pension Credit. + assert ( + not on_pension_credit[i] + or attained_qualifying_age[start : start + len(adults)].all() + ), units[i] + start += len(adults) + len(dependants) + 1 diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py new file mode 100644 index 0000000000..7d2aab8efe --- /dev/null +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -0,0 +1,280 @@ +"""Income-related ESA that is not the award on the reported amounts. + +income_support_eligible reads the claimant's and partner's reported +income-related ESA, after the esa_income capital test. When esa_income holds +anything else (an award entered directly, or a reform that replaces or +removes it), the reported amounts do not say whose award it is, so the gate +takes esa_income itself to be the claimant's or partner's. The test is on the +value the simulation reads, so it holds however that value got there: set +before or after the simulation is built, on a clone or a branch, for this year +or another, deleted and recalculated. + +In most cases below an adult outside the couple reports £3,000 of +income-related ESA, so the award on the reported amounts is £3,000. That +report alone must never bar the claim. Most entered awards are £4,000, which +no reported amount here produces. The later tests vary the reports and the +entered amounts to pin the zero, half-penny and tolerance rules. +""" + +import numpy as np + +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +YEAR = 2025 +CARER = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "receives_carer_benefit": {YEAR: True}, + "income_support_reported": {YEAR: 1_000}, +} +EXCLUDED_ADULT = { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "esa_income_reported": {YEAR: 3_000}, +} +ENTERED = 4_000 + + +def simulation(people, benunit=None): + names = list(people) + return Simulation( + situation={ + "people": people, + "benunits": { + "b": { + "members": names, + "income_support_assessable_capital": {YEAR: 0}, + "esa_income_assessable_capital": {YEAR: 0}, + **(benunit or {}), + } + }, + "households": {"h": {"members": names}}, + } + ) + + +def family(benunit=None): + return simulation({"carer": CARER, "other_adult": EXCLUDED_ADULT}, benunit) + + +def eligible(sim): + sim.delete_arrays("income_support_eligible") + return bool(sim.calculate("income_support_eligible", YEAR)[0]) + + +def test_the_excluded_adults_report_never_bars_the_claim(): + sim = family() + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert eligible(sim) + + +def test_an_award_entered_in_the_situation_bars_the_claim(): + assert not eligible(family({"esa_income": {YEAR: ENTERED}})) + + +def test_an_award_set_after_construction_bars_the_claim(): + sim = family() + assert eligible(sim) + sim.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(sim) + + +def test_an_award_set_on_a_branch_bars_the_claim_there_only(): + sim = family() + branch = sim.get_branch("with_esa", clone_system=False) + branch.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(branch) + assert eligible(sim) + + +def test_an_award_entered_for_another_year_does_not_count_this_year(): + sim = family({"esa_income": {YEAR - 1: 0}}) + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert eligible(sim) + + +def test_a_deleted_award_is_recalculated_from_the_reports(): + sim = family() + sim.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(sim) + sim.delete_arrays("esa_income") + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + assert eligible(sim) + + +def test_an_award_set_on_a_clone_does_not_reach_the_original(): + sim = family() + assert sim.calculate("esa_income", YEAR)[0] == 3_000 + clone = sim.clone() + clone.set_input("esa_income", YEAR, np.array([ENTERED])) + assert not eligible(clone) + assert eligible(sim) + + +def test_nested_branches_read_the_value_they_see(): + sim = family() + outer = sim.get_branch("outer", clone_system=False) + outer.set_input("esa_income", YEAR, np.array([ENTERED])) + inner = outer.get_branch("inner", clone_system=False) + assert not eligible(inner) + inner.set_input("esa_income", YEAR, np.array([0.0])) + assert eligible(inner) + assert not eligible(outer) + assert eligible(sim) + + +def test_a_zero_award_overrides_the_claimants_reported_esa(): + carer_with_esa = {**CARER, "esa_income_reported": {YEAR: 2_000}} + assert not eligible(simulation({"carer": carer_with_esa})) + assert eligible(simulation({"carer": carer_with_esa}, {"esa_income": {YEAR: 0}})) + + +def test_abolishing_income_related_esa_removes_the_bar(): + # A reform that neutralises esa_income pays no income-related ESA, so + # the claimant's reported award no longer bars Income Support. + sim = simulation({"carer": {**CARER, "esa_income_reported": {YEAR: 2_000}}}) + assert not eligible(sim) + sim.tax_benefit_system.neutralize_variable("esa_income") + sim.delete_arrays("esa_income") + assert sim.calculate("esa_income", YEAR)[0] == 0 + assert eligible(sim) + + +def test_the_plain_reported_total_is_read_through_the_reports(): + # disable_simulated_benefits sets esa_income to the plain total of the + # reported awards, before the capital test. With £10,000 of capital the + # tariff income (£832 a year) extinguishes the partner's own £200, so the + # claimant and partner have no income-related ESA; the £3,000 is the + # excluded adult's. + partner = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "esa_income_reported": {YEAR: 200}, + } + sim = simulation( + {"carer": CARER, "partner": partner, "other_adult": EXCLUDED_ADULT}, + {"esa_income_assessable_capital": {YEAR: 10_000}}, + ) + sim.set_input("esa_income", YEAR, np.array([3_200.0])) + assert eligible(sim) + + +def test_an_award_equal_to_the_reported_amounts_is_read_through_them(): + # Intended: an entered award of exactly the £3,000 the reports give is + # read as the excluded adult's award, so it does not bar the claim. + assert eligible(family({"esa_income": {YEAR: 3_000}})) + + +def test_large_reported_awards_match_the_stored_award(): + # esa_income is stored as float32; £65,536.01 + £65,536.00 is stored as + # £131,072.00, a penny below the exact total. The formula's own award must + # still be read through the reports. + big = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: 65_536.01}} + big_too = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: 65_536.00}} + sim = simulation({"carer": CARER, "a": big, "b": big_too}) + assert eligible(sim) + # disable_simulated_benefits enters the plain total the model computes. + raw_total = sim.calculate("esa_income_reported", YEAR, map_to="benunit") + sim.set_input("esa_income", YEAR, raw_total) + assert eligible(sim) + + +def test_a_report_can_change_how_an_entered_award_is_read(): + # Intended: the reports explain an entered award only when they give the + # same amount. £4,000 entered with no reports bars the claim; once an + # excluded adult reports £4,000, the entry is read as theirs. + assert not eligible(simulation({"carer": CARER}, {"esa_income": {YEAR: ENTERED}})) + four_thousand = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: ENTERED}} + assert eligible( + simulation( + {"carer": CARER, "other_adult": four_thousand}, + {"esa_income": {YEAR: ENTERED}}, + ) + ) + + +def test_a_zero_award_never_bars_the_claim(): + # £10.01 + £41.99 sums a little above £52 in stored precision, so after + # £52 of tariff income (capital £6,250) the formula leaves a sub-penny + # residual. That residual is no award, and entering zero, or abolishing + # income-related ESA, pays no ESA, so none of them may bar Income Support. + partner = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "esa_income_reported": {YEAR: 41.99}, + } + carer = {**CARER, "esa_income_reported": {YEAR: 10.01}} + capital = {"esa_income_assessable_capital": {YEAR: 6_250}} + entered_zero = simulation( + {"carer": carer, "partner": partner}, {**capital, "esa_income": {YEAR: 0}} + ) + assert entered_zero.calculate("esa_income", YEAR)[0] == 0 + assert eligible(entered_zero) + abolished = simulation({"carer": carer, "partner": partner}, capital) + abolished.tax_benefit_system.neutralize_variable("esa_income") + assert eligible(abolished) + calculated = simulation({"carer": carer, "partner": partner}, capital) + # A residual above zero, so this case (and the couple's residual in + # test_the_couples_residual_is_no_award_beside_another_members_report) + # exercises the half-penny rule rather than an exact zero. + assert 0 < calculated.calculate("esa_income", YEAR)[0] < 0.005 + assert eligible(calculated) + + +def test_an_entered_amount_within_half_a_penny_of_zero_is_no_award(): + # £0.003 entered beside the carer's own £3,000 report is not what the + # reports give, so it is read as entered; within half a penny of zero it + # is no award. £0.006 is. + carer = {**CARER, "esa_income_reported": {YEAR: 3_000}} + assert eligible(simulation({"carer": carer}, {"esa_income": {YEAR: 0.003}})) + assert not eligible(simulation({"carer": carer}, {"esa_income": {YEAR: 0.006}})) + + +def test_the_couples_residual_is_no_award_beside_another_members_report(): + # An excluded adult's £3,000 makes esa_income a real award, but on the + # couple's own reports (£10.01 + £41.99 against £52 of tariff income) the + # award is only a float residual, so it does not bar the claim. A real + # penny of award (£52.01 against £52) still does. + partner = { + "age": {YEAR: 42}, + "is_claimant_or_partner": {YEAR: True}, + "esa_income_reported": {YEAR: 41.99}, + } + carer = {**CARER, "esa_income_reported": {YEAR: 10.01}} + capital = {"esa_income_assessable_capital": {YEAR: 6_250}} + residual = simulation( + {"carer": carer, "partner": partner, "other_adult": EXCLUDED_ADULT}, capital + ) + assert residual.calculate("esa_income", YEAR)[0] > 2_999 + assert eligible(residual) + penny = simulation( + {"carer": {**CARER, "esa_income_reported": {YEAR: 52.01}}}, capital + ) + assert 0.005 < penny.calculate("esa_income", YEAR)[0] < 0.015 + assert not eligible(penny) + + +def test_the_tolerance_is_half_a_penny(): + # An entered £100 is read through an excluded adult's report of £100.004 + # (within half a penny) but not £100.006, which leaves it the couple's. + def with_report(amount): + reporter = {**EXCLUDED_ADULT, "esa_income_reported": {YEAR: amount}} + return simulation( + {"carer": CARER, "other_adult": reporter}, {"esa_income": {YEAR: 100}} + ) + + assert eligible(with_report(100.004)) + assert not eligible(with_report(100.006)) + + +def test_a_simulation_with_no_inputs_calculates_the_gate(): + sim = Simulation( + situation={}, + scenario=Scenario( + applied_before_data_load=True, + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": False}, + ), + ) + assert sim.calculate("income_support_eligible", YEAR).shape == (1,) diff --git a/policyengine_uk/tests/test_legacy_carer_premium_properties.py b/policyengine_uk/tests/test_legacy_carer_premium_properties.py index 9f7ff50fe4..79c0c51fbf 100644 --- a/policyengine_uk/tests/test_legacy_carer_premium_properties.py +++ b/policyengine_uk/tests/test_legacy_carer_premium_properties.py @@ -8,7 +8,10 @@ caring for the same severely disabled person cannot both be entitled (SSCBA s.70(7ZA); SSI 2023/302 reg 5(3)). The Income Support carer route (Sch 1B para 4, reg 4ZA, SSCBA s.124(1)(e)) likewise needs the claimant to be the -carer, and a couple choose which of them claims. +carer. No new claim for Income Support can be made (UC (Transitional +Provisions) Regs 2014 reg 6A(1)) and a partner who takes over an award does +so by claiming (Claims and Payments Regs 1987 reg 4(4)), so the claimant is +whichever of the claimant and partner holds the existing award. Invariants, for any family of a claimant, an optional partner and up to three dependent children or qualifying young persons: @@ -26,7 +29,8 @@ the model's other kind of award); - supplying "same person" never raises the premium and caps it at one amount; - caring by the claimant or partner never removes Income Support eligibility - or lowers the premium, and below pension age it always opens the IS route. + or lowers the premium, and below pension age it always opens the IS route + when the one who cares holds the Income Support award. Adults are 18 to 85, so pension-age families are covered. No adult under 20 is 16 or more years younger than the other adult, whom the model would then @@ -100,6 +104,9 @@ def families(draw): { "age": draw(st.integers(max(18, head_age - 15), oldest_partner)), "is_parent": has_parent_flag, + # Sometimes both report the award, so the partner can be the + # claimant. + "income_support_reported": draw(st.sampled_from([0, 1_000])), "incapacity_benefit_reported": draw(OVERLAPPING), **draw(carer_inputs()), } @@ -211,10 +218,14 @@ def test_dependants_caring_never_changes_premium_or_is_eligibility(drawn): def test_claimant_or_partner_caring_never_removes_is_eligibility(drawn, data): units = [family for family, _ in drawn] adults = [n for _, n in drawn] - # Make one of the claimant and partner a carer with a reported award. + # Make one of the claimant and partner a carer with a reported Carer's + # Allowance award. with_caring = [] + holds_award = [] for family, n in zip(units, adults): who = data.draw(st.integers(0, n - 1)) + # Caring opens the IS route only for the one who holds the award. + holds_award.append(family[who].get("income_support_reported", 0) > 0) with_caring.append( [ {**member, **CARING} if j == who else member @@ -229,7 +240,7 @@ def test_claimant_or_partner_caring_never_removes_is_eligibility(drawn, data): k = len(units) for i in range(k): assert eligible[k + i] >= eligible[i], units[i] - if not sp_age[offsets[k + i] : offsets[k + i + 1]].any(): + if holds_award[i] and not sp_age[offsets[k + i] : offsets[k + i + 1]].any(): assert eligible[k + i], units[i] assert premium[k + i] >= premium[i] - 0.01, units[i] diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index d6f3d7483e..ed3c43cdf0 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -1,6 +1,15 @@ from policyengine_uk.model_api import * +def income_related_esa_award(benunit, period, reported_award): + """Income-related ESA paid on a reported award: the award less tariff + income from capital, or nothing if the benefit unit fails the screen in + esa_income_eligible.""" + tariff_income = benunit("esa_income_tariff_income", period) + eligible = benunit("esa_income_eligible", period) + return where(eligible, max_(0, reported_award - tariff_income), 0) + + class esa_income(Variable): value_type = float entity = BenUnit @@ -14,6 +23,4 @@ class esa_income(Variable): def formula(benunit, period, parameters): reported_award = add(benunit, period, ["esa_income_reported"]) - tariff_income = benunit("esa_income_tariff_income", period) - eligible = benunit("esa_income_eligible", period) - return where(eligible, max_(0, reported_award - tariff_income), 0) + return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/income_support_eligible.py b/policyengine_uk/variables/gov/dwp/income_support_eligible.py index 7cd2181549..77d554a80f 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_eligible.py +++ b/policyengine_uk/variables/gov/dwp/income_support_eligible.py @@ -1,44 +1,146 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award class income_support_eligible(Variable): value_type = bool entity = BenUnit label = "Whether eligible for Income Support" + documentation = ( + "SSCBA 1992 s.124(1) sets the conditions for the claimant and, for " + "some of them, the claimant's partner. No new claims for Income " + "Support can be made, and a partner who takes over an award makes a " + "new claim, so the claimant is the one of the claimant and partner " + "who has the existing award. The model takes that to be whichever of " + "them reports Income Support (income_support_reported); if both do, " + "either can be the claimant. The bar on new claims (UC (Transitional " + "Provisions) Regs 2014 reg 6A(1)) is in force from 25 July 2022; the " + "model applies this reading in every year as a simplification. The " + "claimant must be under the " + "qualifying age for State Pension Credit, fall within a prescribed " + "category the model covers (a carer, a lone parent of a young child, " + "or a single claimant with a child placed by a local authority) and " + "not be entitled to Employment and Support Allowance. Neither the " + "claimant nor the partner may be entitled to income-related ESA, and " + "the other member of a couple may not be entitled to State Pension " + "Credit, which the model takes to be so where the couple meets the " + "Pension Credit age conditions and would claim it. An " + "adult in the benefit unit who is neither the claimant nor the " + "partner (such as a non-dependent adult) does not affect " + "eligibility, with a declared exception for a stored esa_income " + "that is not the formula's own result. Whether entered directly or " + "replaced by a reform (including one that scales it), such a value " + "is read through the reported awards when it equals what they give " + "(the award after esa_income_eligible, or their plain total, to " + "within half a penny after rounding to the precision it is stored " + "in), and is otherwise taken to be the claimant's or partner's. So " + "another member's report can change how such a value is read." + ) definition_period = YEAR reference = ( - "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", - "https://www.legislation.gov.uk/uksi/1987/1967/regulation/4ZA", "https://www.legislation.gov.uk/ukpga/1992/4/section/124", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/4ZA", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", "https://www.legislation.gov.uk/uksi/1987/1968/regulation/4", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/6A", ) def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - # Schedule 1B para 1 says "under 5". Retain the existing inclusive + person = benunit.members + # SSCBA s.124(1) names the claimant and, in paras (c), (f), (g) and + # (h), the other member of a couple; nobody else in the benefit unit. + claimant_or_partner = person("is_claimant_or_partner", period) + # No new claims for Income Support can be made (Universal Credit + # (Transitional Provisions) Regs 2014 reg 6A(1)). A couple choose + # which of them claims (Claims and Payments Regs 1987 reg 4(3)), but a + # partner who takes over an award does so by claiming (reg 4(4)), so + # the claimant is whichever of them has the existing award. The model + # takes that to be the claimant or partner who reports Income Support. + # Reg 6A was inserted from 25 July 2022 (SI 2022/752); the model + # applies this reading in every year as a simplification. + has_award = claimant_or_partner & ( + person("income_support_reported", period) > 0 + ) + # s.124(1)(e), reg 4ZA and Sch 1B: the claimant falls within a + # prescribed category. The model covers three of them. Para 1 is a + # lone parent responsible for a child under 5; Schedule 1B para 1 + # says "under 5", and the model retains the existing inclusive # comparison pending a separate decision on the annual-age model. + # Para 2 is "a single claimant or a lone parent with whom a child is + # placed" by a local authority or voluntary organisation. A child is + # under 16 (SSCBA s.137(1)). The model's proxy is a member under 16 + # flagged is_looked_after_by_local_authority; the flag also marks a + # child living away in a local authority's care, whom para 2 does not + # cover, and the model cannot tell the two apart. Para 4 is a carer. youngest_child_5_or_under = ( benunit("youngest_child_age_for_legacy_benefits", period) <= IS.eligibility.lone_parent_youngest_child_age_limit ) - person = benunit.members - # SSCBA 1992 s.124(1)(aa) and (e): the claimant has not attained the - # qualifying age for State Pension Credit and is in a prescribed - # category (Sch 1B). Either member of a couple can claim, so a - # mixed-age couple can claim through the younger one, but the same - # person must meet both conditions. - could_claim = person("is_claimant_or_partner", period) & ~person( + lone_parent_with_young_child = ( + benunit("is_lone_parent", period) & youngest_child_5_or_under + ) + placed_child = ( + person("is_looked_after_by_local_authority", period) + & person("is_child_for_child_benefit", period) + & ~claimant_or_partner + ) + single_with_placed_child = benunit("is_single", period) & benunit.any( + placed_child + ) + prescribed_category = person("is_carer_for_benefits", period) | benunit.project( + lone_parent_with_young_child | single_with_placed_child + ) + # s.124(1)(aa): the claimant has not attained the qualifying age for + # State Pension Credit (SPCA 2002 s.1(6)). A partner over that age + # does not bar the claim by that alone; s.124(1)(g) bars it only if + # the other member of the couple is entitled to State Pension Credit + # (on_pension_credit below). + under_qualifying_age = ~person( "has_attained_state_pension_credit_qualifying_age", period ) - # Sch 1B para 1: a lone parent of a young child, who is the only - # claimant or partner in the benefit unit. - lone_parent = benunit("is_lone_parent", period) - lone_parent_with_young_child = ( - lone_parent & youngest_child_5_or_under & benunit.any(could_claim) + # s.124(1)(h): the claimant is not entitled to an employment and + # support allowance of either kind ... + no_contributory_esa = person("esa_contrib", period) <= 0 + claimant = ( + has_award & prescribed_category & under_qualifying_age & no_contributory_esa + ) + # ... and the other member of a couple is not entitled to an + # income-related allowance. An income-related allowance covers the + # couple, so it bars Income Support whichever of them has it: the + # award on the claimant's and partner's reported amounts, after the + # same screen as esa_income (esa_income_eligible). When esa_income + # holds what the reported amounts give, either after that screen (the + # formula) or as their plain total (the disable_simulated_benefits + # reform), the reports say whose award it is. When it holds anything + # else (an award entered directly, or a reform that replaces, scales + # or removes it), they do not, and it is taken to be the claimant's + # or partner's. A stored value equal to either amount, to within half + # a penny after rounding to its stored precision, is read through the + # reports. Either way, no income-related ESA is paid when esa_income + # is zero, so a zero never bars the claim. An amount within half a + # penny of zero counts as zero, the same tolerance as the comparisons: + # reports that exactly match the tariff income can leave a float + # residual of about £0.000002. + esa_income = benunit("esa_income", period) + reported_total = add(benunit, period, ["esa_income_reported"]) + award_on_all_reports = income_related_esa_award(benunit, period, reported_total) + award_on_claimant_or_partner_reports = income_related_esa_award( + benunit, + period, + benunit.sum(person("esa_income_reported", period) * claimant_or_partner), + ) + # Compare in the precision esa_income is stored in (float32), so the + # formula's own award always matches the award recomputed here. + stored = esa_income.dtype + half_penny = 0.005 + as_reported = np.isclose( + esa_income, award_on_all_reports.astype(stored), rtol=0, atol=half_penny + ) | np.isclose( + esa_income, reported_total.astype(stored), rtol=0, atol=half_penny ) - # Sch 1B para 4: a carer. - carer_claimant = benunit.any( - could_claim & person("is_carer_for_benefits", period) + income_related_esa = (esa_income > half_penny) & ( + ~as_reported | (award_on_claimant_or_partner_reports > half_penny) ) # s.124(1)(g): the other member of a couple is not entitled to State # Pension Credit. Entitlement needs a claim (SSAA 1992 s.1). Reading the @@ -52,14 +154,10 @@ def formula(benunit, period, parameters): on_pension_credit = benunit( "meets_pension_credit_age_conditions", period ) & benunit("would_claim_pc", period) - has_esa_income = benunit("esa_income", period) > 0 - already_claiming = add(benunit, period, ["income_support_reported"]) > 0 capital = benunit("income_support_assessable_capital", period) - limit = IS.means_test.capital.limit return ( - (carer_claimant | lone_parent_with_young_child) + benunit.any(claimant) & ~on_pension_credit - & ~has_esa_income - & already_claiming - & (capital <= limit) + & ~income_related_esa + & (capital <= IS.means_test.capital.limit) ) diff --git a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py index de2c1449f3..b50b668733 100644 --- a/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/youngest_child_age_for_legacy_benefits.py @@ -6,9 +6,15 @@ class youngest_child_age_for_legacy_benefits(Variable): entity = BenUnit label = "Age of youngest child for legacy means-tested benefits" documentation = ( - "Youngest family member under 16, excluding the claimant and partner. " - "SSCBA sections 137(1) and 142(1) use the same child age definition. " - "Infinity if the benefit unit contains no such child." + "Youngest child under 16 in the family for the legacy means-tested " + "benefits (is_child_or_young_person_for_legacy_benefits). This " + "excludes the claimant and partner, and a child flagged " + "is_looked_after_by_local_authority: the model's proxy for a child " + "placed with the family by a local authority, or living away in its " + "care, who is not treated as a member of the claimant's household " + "(IS Regs 1987 reg 16(4)-(6)). SSCBA sections 137(1) and 142(1) use " + "the same child age definition. Infinity if the benefit unit contains " + "no such child." ) definition_period = YEAR unit = "year" @@ -16,11 +22,12 @@ class youngest_child_age_for_legacy_benefits(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/section/137", "https://www.legislation.gov.uk/ukpga/1992/4/section/142", "https://www.legislation.gov.uk/uksi/1987/1967/schedule/1B", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/16", ) def formula(benunit, period, parameters): person = benunit.members - child = person("is_child_for_child_benefit", period) & ~person( - "is_claimant_or_partner", period + child = person("is_child_for_child_benefit", period) & person( + "is_child_or_young_person_for_legacy_benefits", period ) return benunit.min(where(child, person("age", period), np.inf))