diff --git a/changelog.d/1956.fixed.md b/changelog.d/1956.fixed.md new file mode 100644 index 0000000000..7c9695e5ba --- /dev/null +++ b/changelog.d/1956.fixed.md @@ -0,0 +1 @@ +- Targeted (2-year-old) childcare now qualifies through Pension Credit only when the family receives a guarantee credit, so pensioners who do not claim Pension Credit no longer qualify through a computed guarantee credit. Receipt of a guarantee credit now also requires a Pension Credit payment, so a reform that stops Pension Credit being paid closes the routes that read it. diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index 3b41a8f8a5..4b3e3e6ef7 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -26,6 +26,8 @@ "* **Income-related Employment and Support Allowance (ESA)**\n", "* **Pension Credit (Guarantee Credit)**\n", "\n", + "The regulations require the parent to be entitled to the guaranteed element of State Pension Credit. Entitlement needs the claimant to have attained the qualifying age ([State Pension Credit Act 2002 s.1(2)(b)](https://www.legislation.gov.uk/ukpga/2002/16/section/1)), excludes a couple whose other member has not ([s.4(1A)](https://www.legislation.gov.uk/ukpga/2002/16/section/4)) unless [SI 2019/37 art 4](https://www.legislation.gov.uk/uksi/2019/37/article/4) saves them, and needs a claim ([Social Security Administration Act 1992 s.1](https://www.legislation.gov.uk/ukpga/1992/5/section/1)). PolicyEngine counts Pension Credit when the family receives a guarantee credit: the claimant has reached State Pension age, and so has any partner unless the couple is saved by SI 2019/37 art 4; the family claims Pension Credit and is paid it; and its guarantee credit is positive. A working-age family therefore never qualifies through Pension Credit in the model, even when its income is below the minimum guarantee, and nor does a pensioner who does not claim it. A mixed-age couple qualifies when the model treats it as saved by SI 2019/37 art 4, which it infers from reported Pension Credit or pension-age Housing Benefit unless the saving is set directly. The model does not distinguish entitlement from payment. In law they can differ, for example where a credit of under 10p a week is not payable ([SI 2002/1792 reg 13](https://www.legislation.gov.uk/uksi/2002/1792/regulation/13)).\n", + "\n", "#### Eligibility conditions\n", "Families may also qualify based on income thresholds if they receive:\n", "* **Universal Credit** with household earned income of **£15,400 or less** per year\n", diff --git a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml index 6cf7f7b91a..04b4634d15 100644 --- a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml +++ b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml @@ -1,10 +1,12 @@ -description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. The child's parent must be entitled to one, so income-based JSA and income-related ESA count only as the claimant's or partner's award. +description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. The child's parent must be entitled to one, so income-based JSA and income-related ESA count only as the claimant's or partner's award, and Pension Credit counts only as receipt of a guarantee credit, which needs the qualifying age and a claim, rather than the guarantee credit amount, which is computed whether or not the family claims. metadata: reference: - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) Regulations 2014 - regulation 1(2), "eligible child" href: https://www.legislation.gov.uk/uksi/2014/2147/regulation/1 - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) (Amendment) Regulations 2018 - part 2.a href: https://www.legislation.gov.uk/uksi/2018/146/regulation/2/made + - title: State Pension Credit Act 2002 - section 1(2), entitlement requires the qualifying age + href: https://www.legislation.gov.uk/ukpga/2002/16/section/1 period: year unit: program label: Qualifying benefits for targeted childcare entitlement @@ -13,4 +15,4 @@ values: - income_support - claimant_or_partner_jsa_income - claimant_or_partner_esa_income - - guarantee_credit \ No newline at end of file + - in_receipt_of_guarantee_credit diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_eligible.yaml index a2a8e803c1..6a15c085df 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_eligible.yaml @@ -10,7 +10,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_working_tax_credit_criteria_for_targeted_childcare_entitlement: true extended_childcare_entitlement_eligible: false output: @@ -28,7 +28,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_working_tax_credit_criteria_for_targeted_childcare_entitlement: true meets_universal_credit_criteria_for_targeted_childcare_entitlement: false extended_childcare_entitlement_eligible: false @@ -47,7 +47,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_universal_credit_criteria_for_targeted_childcare_entitlement: false working_tax_credit_run_on: true extended_childcare_entitlement_eligible: false @@ -66,7 +66,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_universal_credit_criteria_for_targeted_childcare_entitlement: false meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: true extended_childcare_entitlement_eligible: false @@ -85,7 +85,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_universal_credit_criteria_for_targeted_childcare_entitlement: false meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: true extended_childcare_entitlement_eligible: false @@ -104,7 +104,7 @@ income_support: 0 jsa_income: 0 esa_income: 0 - guarantee_credit: 0 + in_receipt_of_guarantee_credit: false meets_universal_credit_criteria_for_targeted_childcare_entitlement: true extended_childcare_entitlement_eligible: false output: diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_guarantee_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_guarantee_credit.yaml new file mode 100644 index 0000000000..01ca6e6035 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_guarantee_credit.yaml @@ -0,0 +1,431 @@ +# The Guarantee Credit route into the 2-year-old entitlement. +# SI 2014/2147 reg 1(2), "eligible child": a young child "whose parent is +# entitled to any one or more of the following— ... the guaranteed element of +# state pension credit under the State Pension Credit Act 2002". Entitlement +# to state pension credit needs the claimant to have "attained the qualifying +# age" (SPCA 2002 s.1(2)(b)) and a claim, so the route follows +# in_receipt_of_guarantee_credit (eligible for Pension Credit, claiming it, +# paid it, with a positive guarantee credit) and not the guarantee credit +# amount, which PolicyEngine computes whether or not the family claims. +# The minimum_guarantee outputs show the income test a family below the +# qualifying age would otherwise pass. +# 2026 parameters: standard minimum guarantee £238.00 a week single and +# £363.25 couple (12,376 and 18,889 a year); child addition £69.98 a week +# (3,638.96 a year); Savings Credit threshold £208.07 a week single +# (10,819.64 a year), phase-in 60%, phase-out 40%. Ages avoid the State +# Pension age boundary: 65 is below it and 67 or over is above it in 2026 +# both under the flat age-66 parameter and under the date-of-birth rule. + +- name: Case 1, working-age lone parent with a 2-year-old and no income does not qualify through a computed guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Income of 0 is below the minimum guarantee of 12,376 + 3,638.96 = + # 16,014.96, but a 30-year-old has not attained the qualifying age, so + # there is no guarantee credit and no Pension Credit. + minimum_guarantee: 16_014.96 + is_guarantee_credit_eligible: true + guarantee_credit: 0 + is_pension_credit_eligible: false + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 2, working-age couple with a 2-year-old and no income does not qualify through a computed guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + person2: + age: 32 + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + would_claim_uc: false + households: + household: + members: [person1, person2, person3] + output: + # Minimum guarantee 18,889 + 3,638.96 = 22,527.96; neither member has + # attained the qualifying age. + minimum_guarantee: 22_527.96 + is_guarantee_credit_eligible: true + guarantee_credit: 0 + is_pension_credit_eligible: false + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false, false] + +- name: Case 3, parent aged 65, below the qualifying age, does not qualify through a computed guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 65 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + minimum_guarantee: 16_014.96 + is_guarantee_credit_eligible: true + guarantee_credit: 0 + is_pension_credit_eligible: false + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 4, parent over the qualifying age with no income, a guarantee credit and a 2-year-old qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 67 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # 12,376 + 3,638.96 = 16,014.96, paid as Pension Credit. + guarantee_credit: 16_014.96 + is_pension_credit_eligible: true + pension_credit: 16_014.96 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, true] + +- name: Case 5, pension-age parent with a State Pension below the minimum guarantee and a 2-year-old qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + state_pension_reported: 10_000 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + households: + household: + members: [person1, person2] + output: + # 16,014.96 - 10,000 = 6,014.96. + guarantee_credit: 6_014.96 + pension_credit: 6_014.96 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, true] + +- name: Case 6, the same pension-age parent not claiming Pension Credit does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + state_pension_reported: 10_000 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: false + households: + household: + members: [person1, person2] + output: + # The guarantee credit is still computed, but without a claim there is + # no entitlement. + guarantee_credit: 6_014.96 + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 7, pension-age parent with income above the minimum guarantee does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + state_pension_reported: 20_000 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + households: + household: + members: [person1, person2] + output: + # Income tax (20,000 - 12,570) x 20% = 1,486, so income is 18,514, + # above 16,014.96. A single person who reached pensionable age on or + # after 6 April 2016 cannot get Savings Credit (SPCA s.3(1)). + income_tax: [1_486, 0] + guarantee_credit: 0 + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 8, Savings Credit alone is not the guaranteed element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + # Reached State Pension age long before April 2016, so can get + # Savings Credit. + age: 90 + state_pension_reported: 17_000 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + households: + household: + members: [person1, person2] + output: + # Income tax (17,000 - 12,570) x 20% = 886, so income is 16,114, above + # 16,014.96: no guarantee credit. Savings Credit: maximum + # 0.6 x (12,376 - 10,819.64) = 933.816, less + # 0.4 x (16,114 - 16,014.96) = 39.616; = 894.20. + income_tax: [886, 0] + guarantee_credit: 0 + savings_credit: 894.20 + pension_credit: 894.20 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 9, mixed-age couple without the SI 2019/37 art 4 saving does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 10_000 + person2: + age: 40 + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3] + output: + # Minimum guarantee 18,889 + 3,638.96 = 22,527.96, above income of + # 10,000. From 15 May 2019 SPCA s.4(1A) excludes a mixed-age couple + # unless SI 2019/37 art 4 saves it (entitled to Pension Credit or Housing + # Benefit as that couple on 14 May 2019). The couple reports neither, so + # PolicyEngine infers no saving. + has_mixed_age_couple_pension_credit_saving: false + minimum_guarantee: 22_527.96 + is_guarantee_credit_eligible: true + guarantee_credit: 0 + is_pension_credit_eligible: false + pension_credit: 0 + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false, false] + +- name: Case 10, pension-age couple receiving a guarantee credit in Wales does not qualify for the England entitlement. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 5_000 + person2: + age: 68 + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + would_claim_pc: true + households: + household: + members: [person1, person2, person3] + region: WALES + output: + # 18,889 + 3,638.96 - 5,000 = 17,527.96. + guarantee_credit: 17_527.96 + pension_credit: 17_527.96 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false, false] + +- name: Case 11, working-age lone parent on Universal Credit with no earnings still qualifies through Universal Credit. + period: 2026 + input: + people: + person1: + age: 30 + person2: + age: 2 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: true + households: + household: + members: [person1, person2] + output: + in_receipt_of_guarantee_credit: false + meets_universal_credit_criteria_for_targeted_childcare_entitlement: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, true] + +- name: Case 12, pension-age parent with a 17-year-old in non-advanced education and a 2-year-old qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + is_parent: true + state_pension_reported: 10_000 + person2: + age: 17 + is_in_non_advanced_education: true + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3] + output: + # The 17-year-old is a qualifying young person, not a partner. Minimum + # guarantee: single 12,376, plus the child addition for a first child + # born before 6 April 2017 (81.07 x 52 = 4,215.64) and for the + # 2-year-old (69.98 x 52 = 3,638.96), = 20,230.60. Less the State + # Pension of 10,000, the guarantee credit is 10,230.60. + minimum_guarantee: 20_230.60 + is_claimant_or_partner: [true, false, false] + guarantee_credit: 10_230.60 + pension_credit: 10_230.60 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, false, true] + +- name: Case 13, the same family qualifies after the young person turns 18. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + is_parent: true + state_pension_reported: 10_000 + person2: + age: 18 + is_in_non_advanced_education: true + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3] + output: + # SPCA 2002 s.1(2)(b) asks whether the claimant has attained the + # qualifying age, and s.4(1A) whether the other member of a couple has. + # An 18-year-old in non-advanced education is neither, so the family's + # eligibility and award are as in Case 12. + is_claimant_or_partner: [true, false, false] + is_pension_credit_eligible: true + guarantee_credit: 10_230.60 + pension_credit: 10_230.60 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, false, true] + +- name: Case 14, mixed-age couple with the SI 2019/37 art 4 saving, a guarantee credit and a 2-year-old qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_reported: 10_000 + person2: + age: 40 + person3: + age: 2 + benunits: + benunit: + members: [person1, person2, person3] + has_mixed_age_couple_pension_credit_saving: true + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3] + output: + # A mixed-age couple saved by SI 2019/37 art 4, so SPCA s.4(1A) does not + # apply. The older member, aged 80, had reached the qualifying age by + # 14 May 2019, as the saving requires. Couple minimum guarantee + # 18,889 + 3,638.96 = 22,527.96, less the State Pension of 10,000, + # = 12,527.96. The couple's Savings Credit threshold (329.75 a week, + # 17,147 a year) is above its income, so no Savings Credit is added. + meets_pension_credit_age_conditions: true + is_pension_credit_eligible: true + guarantee_credit: 12_527.96 + pension_credit: 12_527.96 + in_receipt_of_guarantee_credit: true + targeted_childcare_entitlement_eligible: true + is_child_receiving_targeted_childcare: [false, false, true] diff --git a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py new file mode 100644 index 0000000000..af04dedcee --- /dev/null +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -0,0 +1,528 @@ +"""Property-based tests for the Guarantee Credit route into targeted childcare. + +An "eligible child" is a young child "whose parent is entitled to any one or +more of the following— ... the guaranteed element of state pension credit +under the State Pension Credit Act 2002" (SI 2014/2147 reg 1(2)). A claimant +is entitled to state pension credit only if "he has attained the qualifying +age" (SPCA 2002 s.1(2)(b)), and entitlement needs a claim (SSAA 1992 s.1). + +Three versions of the route are compared: + +- filed: the route when #1956 was filed, a guarantee credit computed for + every benefit unit, max(0, minimum guarantee - Pension Credit income), + which is positive for low-income working-age families; +- main: `guarantee_credit` > 0. Since #1896 that is zero unless the benefit + unit is eligible for Pension Credit, but it is still positive for an + eligible family that does not claim Pension Credit; +- fix: `in_receipt_of_guarantee_credit`, which needs Pension Credit + eligibility, a claim, a Pension Credit payment and a positive guarantee + credit. + +Invariants, for any generated population of families with a 2-year-old, some +also with a dependent young person aged 16 to 19 in non-advanced education: + +1. Qualifying age: no benefit unit whose adults are all below the qualifying + age qualifies through the Guarantee Credit route, however low its income. + Fails for filed; holds for main and fix. +2. Route identity: targeted childcare eligibility equals claiming it, living + in England, not being eligible for the extended entitlement, and having at + least one route open: Income Support, income-based JSA, income-related + ESA, receipt of a guarantee credit, or a qualifying criterion (Universal + Credit, and tax credits while they qualify). +3. Receipt: in the unreformed model the Guarantee Credit route is open + exactly when pension_credit and guarantee_credit are both positive. It + implies would_claim_pc and that the claimant and any partner are over + State Pension age; a dependent young person's age does not matter. No + generated family reports Pension Credit or pension-age Housing Benefit, so + the model infers no SI 2019/37 art 4 saving for a generated mixed-age + couple. A saved mixed-age couple is in receipt (YAML Case 14). +4. Differential against main: the fix never adds eligibility, and removes it + exactly where a guarantee credit is computed, no Pension Credit is paid, + and no other route is open (intended: the eligible non-claimant). +5. Metamorphic: not claiming Pension Credit closes the route and never makes + a family eligible. Fails for filed and main; holds for fix. +6. Not claiming targeted childcare never leaves a family eligible. +7. Payment: a reform that stops Pension Credit being paid closes the route, + with or without the Pension Credit freeze (fixed examples). +8. Under the Pension Credit freeze the route follows the baseline receipt, + even where the reformed guarantee credit is zero, so invariants 3 and 4 + are stated for the unreformed model only (fixed example). +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import YEAR as YEAR_PERIOD +from policyengine_uk.model_api import BenUnit, Variable +from policyengine_uk.utils.scenario import Scenario + +# Tax credits still qualify in 2025; only Universal Credit does from 2026. +YEARS = [2025, 2026, 2029] +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +OUTSIDE_ENGLAND = ["WALES", "SCOTLAND", "NORTHERN_IRELAND"] +REGIONS = ["LONDON", "NORTH_EAST", "SOUTH_WEST"] + OUTSIDE_ENGLAND +# State Pension age is at least 66 in every test year, under the flat age-66 +# parameter and under the date-of-birth rule with the rise to 67, so 65 and +# under is always below it. 67 and over is above it in 2025 and 2026, where +# the known families sit; no invariant needs it to be above it in 2029. +PENSION_AGE = st.integers(67, 100) +WORKING_AGE = st.integers(18, 65) +SHAPES = { + "single_working": [WORKING_AGE], + "couple_working": [WORKING_AGE, WORKING_AGE], + "single_pension": [PENSION_AGE], + "couple_pension": [PENSION_AGE, PENSION_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], +} +# A dependent young person in non-advanced education: 16 and 17 are children +# for HBAI, and 18 and 19 are dependants because the benefit unit has an +# identified parent. None of them is the claimant or partner. +YOUNG_PERSON_AGES = [16, 17, 18, 19] +LEGACY_BENEFITS = [ + "income_support_reported", + "jsa_income_reported", + "esa_income_reported", +] +money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(sorted(SHAPES))) + ages = [draw(age) for age in SHAPES[shape]] + young_person = None + # The young person must not be the eldest member (the benefit-unit head). + if min(ages) >= 36: + young_person = draw(st.sampled_from([None, None] + YOUNG_PERSON_AGES)) + return dict( + ages=ages, + young_person=young_person, + region=draw(st.sampled_from(REGIONS)), + earnings=draw(st.one_of(st.just(0.0), money)), + state_pension=draw(st.one_of(st.just(0.0), money)), + private_pension=draw(st.one_of(st.just(0.0), money)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 40_000))), + would_claim_pc=draw(st.booleans()), + would_claim_uc=draw(st.booleans()), + would_claim_targeted_childcare=draw(st.sampled_from([True, True, False])), + legacy_benefit=draw(st.sampled_from([None, None, None] + LEGACY_BENEFITS)), + ) + + +def family(ages, **overrides): + unit = dict( + ages=ages, + young_person=None, + region="LONDON", + earnings=0.0, + state_pension=0.0, + private_pension=0.0, + savings=0.0, + would_claim_pc=True, + would_claim_uc=False, + would_claim_targeted_childcare=True, + legacy_benefit=None, + ) + unit.update(overrides) + return unit + + +# One family for each side of every rule. +KNOWN_FAMILIES = [ + family([30]), + family([30, 32]), + family([65]), + family([30], would_claim_uc=True), + family([68], state_pension=10_000.0), + family([68], state_pension=10_000.0, would_claim_pc=False), + family([68], state_pension=20_000.0), + family([70, 40], state_pension=10_000.0), + family([70, 68], state_pension=5_000.0, region="WALES"), + family([40], legacy_benefit="esa_income_reported"), + family([68], state_pension=10_000.0, young_person=18), + family([68], state_pension=10_000.0, would_claim_targeted_childcare=False), +] +BELOW_AGE_FAMILIES = [0, 1, 2] # No other route open, income below the MG. +NON_CLAIMANT = 5 +POPULATIONS = st.tuples( + st.sampled_from(YEARS), st.lists(families(), min_size=1, max_size=30) +) + + +def situation(year, units, would_claim_pc=None, would_claim_tce=None): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"p{i}_{j}" + person = {"age": {year: age}, "is_parent": {year: True}} + if age >= 67: + person["state_pension_reported"] = {year: unit["state_pension"]} + person["private_pension_income"] = {year: unit["private_pension"]} + elif j == 0: + person["employment_income"] = {year: unit["earnings"]} + if unit["legacy_benefit"]: + person[unit["legacy_benefit"]] = {year: 3_000.0} + people[name] = person + names.append(name) + if unit["young_person"] is not None: + name = f"y{i}" + people[name] = { + "age": {year: unit["young_person"]}, + "is_in_non_advanced_education": {year: True}, + } + names.append(name) + child = f"c{i}" + people[child] = {"age": {year: 2}} + names.append(child) + claims_pc = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc + claims_tce = ( + unit["would_claim_targeted_childcare"] + if would_claim_tce is None + else would_claim_tce + ) + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {year: claims_pc}, + "would_claim_uc": {year: unit["would_claim_uc"]}, + "would_claim_targeted_childcare": {year: claims_tce}, + } + households[f"h{i}"] = { + "members": names, + "region": {year: unit["region"]}, + "savings": {year: unit["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def route(label, formula): + # A stand-in for in_receipt_of_guarantee_credit, to run an earlier + # version of the route through the same eligibility formula. + return type( + "in_receipt_of_guarantee_credit", + (Variable,), + dict( + value_type=bool, + entity=BenUnit, + definition_period=YEAR_PERIOD, + label=label, + formula=formula, + ), + ) + + +ROUTES = { + "fix": None, + "main": route( + "guarantee credit computed for a Pension Credit eligible family", + lambda benunit, period, parameters: benunit("guarantee_credit", period) > 0, + ), + "filed": route( + "guarantee credit computed for every benefit unit", + lambda benunit, period, parameters: ( + benunit("minimum_guarantee", period) + > benunit("pension_credit_income", period) + ), + ), +} + +VARIABLES = [ + "targeted_childcare_entitlement_eligible", + "would_claim_targeted_childcare", + "extended_childcare_entitlement_eligible", + "in_receipt_of_guarantee_credit", + "guarantee_credit", + "pension_credit", + "is_pension_credit_eligible", + "would_claim_pc", + "income_support", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", +] +BOOLEANS = [ + "targeted_childcare_entitlement_eligible", + "would_claim_targeted_childcare", + "extended_childcare_entitlement_eligible", + "in_receipt_of_guarantee_credit", + "is_pension_credit_eligible", + "would_claim_pc", +] + + +def calculate(year, units, version="fix", reform=None, scenario=None, **kwargs): + sim = Simulation( + situation=situation(year, units, **kwargs), reform=reform, scenario=scenario + ) + if ROUTES[version] is not None: + sim.tax_benefit_system.update_variable(ROUTES[version]) + values = {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + parameters = sim.tax_benefit_system.parameters(str(year)) + criteria = parameters.gov.dfe.targeted_childcare_entitlement.qualifying_criteria + values["criteria"] = ( + sum(np.asarray(sim.calculate(v, year)).astype(int) for v in criteria) > 0 + ) + values["legacy"] = ( + values["income_support"] + + values["claimant_or_partner_jsa_income"] + + values["claimant_or_partner_esa_income"] + ) > 0 + values["in_england"] = np.array( + [unit["region"] not in OUTSIDE_ENGLAND for unit in units] + ) + values["any_over_sp_age"] = ( + np.asarray(sim.calculate("is_SP_age", year, map_to="benunit")) > 0 + ) + for name in BOOLEANS: + values[name] = values[name].astype(bool) + return values + + +def all_adults_below_qualifying_age(unit): + # Every member aged 18 or over, including a dependent young person. + adults = list(unit["ages"]) + if unit["young_person"] is not None and unit["young_person"] >= 18: + adults.append(unit["young_person"]) + return all(age <= 65 for age in adults) + + +def claimant_and_partner_over_sp_age(unit): + return all(age >= 67 for age in unit["ages"]) + + +def qualifying_age_violations(units, values): + below = np.array([all_adults_below_qualifying_age(unit) for unit in units]) + eligible = values["targeted_childcare_entitlement_eligible"] + other_route = values["legacy"] | values["criteria"] + return below & eligible & ~other_route + + +def claim_violations(not_claiming): + # Families eligible without a Pension Credit claim through no other route. + return not_claiming["targeted_childcare_entitlement_eligible"] & ~( + not_claiming["legacy"] | not_claiming["criteria"] + ) + + +@PROPERTY_SETTINGS +@given(POPULATIONS) +@example((2026, KNOWN_FAMILIES)) +@example((2025, KNOWN_FAMILIES)) +def test_no_family_below_the_qualifying_age_qualifies_through_guarantee_credit( + population, +): + year, units = population + values = calculate(year, units) + below = np.array([all_adults_below_qualifying_age(unit) for unit in units]) + # The generated ages agree with the model's State Pension age. + assert not np.any(below & values["any_over_sp_age"]) + assert not np.any(below & values["in_receipt_of_guarantee_credit"]) + assert not np.any(below & (values["pension_credit"] > 0)) + assert not np.any(qualifying_age_violations(units, values)) + + +@PROPERTY_SETTINGS +@given(POPULATIONS) +@example((2026, KNOWN_FAMILIES)) +@example((2025, KNOWN_FAMILIES)) +def test_eligibility_is_the_union_of_the_routes(population): + year, units = population + values = calculate(year, units) + receipt = values["in_receipt_of_guarantee_credit"] + paid = (values["pension_credit"] > 0) & (values["guarantee_credit"] > 0) + assert np.array_equal(receipt, paid) + assert not np.any(receipt & ~values["is_pension_credit_eligible"]) + assert not np.any(receipt & ~values["would_claim_pc"]) + for i, unit in enumerate(units): + if receipt[i]: + assert claimant_and_partner_over_sp_age(unit), unit + expected = ( + values["would_claim_targeted_childcare"] + & values["in_england"] + & ~values["extended_childcare_entitlement_eligible"] + & (values["legacy"] | values["criteria"] | receipt) + ) + assert np.array_equal(values["targeted_childcare_entitlement_eligible"], expected) + # Not claiming targeted childcare never leaves a family eligible. + assert not np.any( + values["targeted_childcare_entitlement_eligible"] + & ~values["would_claim_targeted_childcare"] + ) + if units is KNOWN_FAMILIES: + assert receipt.tolist() == [ + False, + False, + False, + False, + True, + False, # Not claiming Pension Credit. + False, + False, + True, + False, + True, # An 18-year-old dependant is not a partner. + True, + ] + assert values["targeted_childcare_entitlement_eligible"].tolist() == [ + False, + False, + False, + True, # Universal Credit with no earnings. + True, # Paid a guarantee credit. + False, + False, + False, + False, # Paid a guarantee credit, but in Wales. + True, # Income-related ESA. + True, # Paid a guarantee credit, with an 18-year-old dependant. + False, # Paid a guarantee credit, but not claiming childcare. + ] + + +@PROPERTY_SETTINGS +@given(POPULATIONS) +@example((2026, KNOWN_FAMILIES)) +@example((2025, KNOWN_FAMILIES)) +def test_fix_only_removes_the_unpaid_guarantee_credit_route(population): + year, units = population + new = calculate(year, units) + old = calculate(year, units, version="main") + new_eligible = new["targeted_childcare_entitlement_eligible"] + old_eligible = old["targeted_childcare_entitlement_eligible"] + # Only the route changes: every other input to eligibility is the same. + for name in ["guarantee_credit", "pension_credit", "legacy", "criteria"]: + assert np.array_equal(new[name], old[name]), name + assert not np.any(new_eligible & ~old_eligible) + base = ( + new["would_claim_targeted_childcare"] + & new["in_england"] + & ~new["extended_childcare_entitlement_eligible"] + ) + computed_not_paid = (new["guarantee_credit"] > 0) & ~(new["pension_credit"] > 0) + removed = base & computed_not_paid & ~new["legacy"] & ~new["criteria"] + assert np.array_equal(old_eligible & ~new_eligible, removed) + # Main computes a guarantee credit only for Pension Credit eligible + # families, so everyone who loses the route is an eligible non-claimant. + assert not np.any(removed & ~new["is_pension_credit_eligible"]) + assert not np.any(removed & new["would_claim_pc"]) + # Where an eligible family claims Pension Credit, nothing changes. + claimed = new["would_claim_pc"] & new["is_pension_credit_eligible"] + assert np.array_equal(new_eligible[claimed], old_eligible[claimed]) + if units is KNOWN_FAMILIES: + expected = [False] * len(KNOWN_FAMILIES) + expected[NON_CLAIMANT] = True + assert removed.tolist() == expected + + +@PROPERTY_SETTINGS +@given(POPULATIONS) +@example((2026, KNOWN_FAMILIES)) +def test_not_claiming_pension_credit_never_makes_a_family_eligible(population): + year, units = population + claiming = calculate(year, units, would_claim_pc=True) + not_claiming = calculate(year, units, would_claim_pc=False) + assert not np.any(not_claiming["in_receipt_of_guarantee_credit"]) + assert not np.any( + not_claiming["targeted_childcare_entitlement_eligible"] + & ~claiming["targeted_childcare_entitlement_eligible"] + ) + # Without a Pension Credit claim only the other routes are left. + assert not np.any(claim_violations(not_claiming)) + + +def test_known_families_detect_the_earlier_routes(): + # The properties are not vacuous: each earlier version of the route + # breaks the invariant it was written against, on the known families. + year = 2026 + filed = calculate(year, KNOWN_FAMILIES, version="filed") + assert ( + qualifying_age_violations(KNOWN_FAMILIES, filed).nonzero()[0].tolist() + == BELOW_AGE_FAMILIES + ) + main = calculate(year, KNOWN_FAMILIES, version="main") + assert not np.any(qualifying_age_violations(KNOWN_FAMILIES, main)) + not_claiming = { + version: calculate(year, KNOWN_FAMILIES, version=version, would_claim_pc=False) + for version in ROUTES + } + for version in ["filed", "main"]: + assert claim_violations(not_claiming[version])[NON_CLAIMANT], version + assert not np.any(claim_violations(not_claiming["fix"])) + + +def test_stopping_pension_credit_payments_closes_the_route(): + # A reform that stops Pension Credit being paid leaves the guarantee + # credit computed but ends receipt, and with it the route. + year = 2026 + units = [family([68], state_pension=10_000.0)] + stopped = calculate( + year, + units, + scenario=Scenario( + simulation_modifier=lambda sim: sim.tax_benefit_system.neutralize_variable( + "pension_credit" + ) + ), + ) + # 12,376 + 3,638.96 - 10,000 = 6,014.96. + assert abs(stopped["guarantee_credit"][0] - 6_014.96) < 0.01 + assert stopped["pension_credit"].tolist() == [0] + assert stopped["in_receipt_of_guarantee_credit"].tolist() == [False] + assert stopped["targeted_childcare_entitlement_eligible"].tolist() == [False] + + # The same holds with Pension Credit frozen: the frozen award is not + # paid either, so the baseline receipt does not carry over. + frozen_and_stopped = calculate( + year, + units, + scenario=Scenario( + parameter_changes={"gov.contrib.freeze_pension_credit": True}, + simulation_modifier=lambda sim: sim.tax_benefit_system.neutralize_variable( + "pension_credit" + ), + ), + ) + assert frozen_and_stopped["pension_credit"].tolist() == [0] + assert frozen_and_stopped["in_receipt_of_guarantee_credit"].tolist() == [False] + assert frozen_and_stopped["targeted_childcare_entitlement_eligible"].tolist() == [ + False + ] + + +def test_pension_credit_freeze_keeps_the_baseline_route(): + # With Pension Credit frozen the baseline award is paid, so receipt of a + # guarantee credit is the baseline receipt. A reform setting the single + # minimum guarantee to zero ends the computed guarantee credit; the + # pension-age parent keeps the frozen award and the route, and the + # working-age parent never had either. + year = 2026 + units = [family([68], state_pension=10_000.0), family([30])] + no_minimum_guarantee = { + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE": 0, + "gov.dwp.pension_credit.guarantee_credit.child.addition": 0, + } + frozen = calculate( + year, + units, + reform={**no_minimum_guarantee, "gov.contrib.freeze_pension_credit": True}, + ) + assert frozen["guarantee_credit"].tolist() == [0, 0] + # 12,376 + 3,638.96 - 10,000 = 6,014.96 in the baseline. + assert abs(frozen["pension_credit"][0] - 6_014.96) < 0.01 + assert frozen["pension_credit"][1] == 0 + assert frozen["in_receipt_of_guarantee_credit"].tolist() == [True, False] + assert frozen["targeted_childcare_entitlement_eligible"].tolist() == [True, False] + + unfrozen = calculate(year, units, reform=no_minimum_guarantee) + assert unfrozen["pension_credit"].tolist() == [0, 0] + assert unfrozen["in_receipt_of_guarantee_credit"].tolist() == [False, False] + assert unfrozen["targeted_childcare_entitlement_eligible"].tolist() == [ + False, + False, + ] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py index 1127e6753d..76f76e3e25 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py @@ -6,15 +6,17 @@ class in_receipt_of_guarantee_credit(Variable): documentation = ( "Whether the claimant or partner in this benefit unit is in receipt of " "a guarantee credit: the benefit unit is eligible for Pension Credit, " - "claims it, and its Guarantee Credit is positive. A Guarantee Credit " - "computed for a family that does not claim Pension Credit, or that the " - "model does not treat as eligible for it (for example a working-age " - "family), is not received. A mixed-age couple that keeps Pension " - "Credit under the SI 2019/37 article 4 saving is eligible " - "(meets_pension_credit_age_conditions), so it is in receipt when it " - "claims and its Guarantee Credit is positive. Under " - "the Pension Credit freeze the baseline receipt is kept, because the " - "frozen award is the baseline award." + "claims it, is paid Pension Credit, and its Guarantee Credit is " + "positive. A Guarantee Credit computed for a family that does not " + "claim Pension Credit, or that the model does not treat as eligible " + "for it (for example a working-age family), is not received, and " + "nor is one under a reform that stops Pension Credit being paid. A " + "mixed-age couple that keeps Pension Credit under the SI 2019/37 " + "article 4 saving is eligible (meets_pension_credit_age_conditions), " + "so it is in receipt when it claims, is paid Pension Credit and its " + "Guarantee Credit is positive. Under the Pension Credit freeze the " + "baseline receipt is kept while Pension Credit is still paid, because " + "the frozen award is the baseline award." ) entity = BenUnit definition_period = YEAR @@ -27,13 +29,14 @@ class in_receipt_of_guarantee_credit(Variable): ) def formula(benunit, period, parameters): + paid = benunit("pension_credit", period) > 0 freeze = parameters(period).gov.contrib.freeze_pension_credit baseline = benunit.simulation.baseline if freeze and baseline is not None: - return baseline.populations["benunit"]( + return paid & baseline.populations["benunit"]( "in_receipt_of_guarantee_credit", period ) eligible = benunit("is_pension_credit_eligible", period) would_claim = benunit("would_claim_pc", period) guarantee_credit = benunit("guarantee_credit", period) - return eligible & would_claim & (guarantee_credit > 0) + return eligible & would_claim & paid & (guarantee_credit > 0)