From 1ddf903bd03ee84e41e25302698f29625780e50d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:43:03 -0400 Subject: [PATCH 1/6] Qualify targeted childcare through Pension Credit only on receipt The 2-year-old entitlement listed the raw guarantee_credit amount as a qualifying benefit. PolicyEngine computes that amount for every benefit unit, so low-income working-age families and pensioners who do not claim Pension Credit qualified through it. SI 2014/2147 reg 1(2) requires the parent to be entitled to the guaranteed element of state pension credit, and entitlement needs the qualifying age (SPCA 2002 s.1(2)(b)) and a claim (SSAA 1992 s.1(1), (4)(ab)). The entry is now in_receipt_of_guarantee_credit (Pension Credit eligible, claims it, guarantee credit positive), the variable #1927 and #1909 add; the file is byte-identical to theirs. Its YAML test is #1927's, also byte-identical. Fixes #1956 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1956.fixed.md | 1 + .../dfe/targeted-childcare-entitlement.ipynb | 2 + .../qualifying_benefits.yaml | 8 +- ...rgeted_childcare_entitlement_eligible.yaml | 12 +- ...hildcare_entitlement_guarantee_credit.yaml | 306 ++++++++++++++ .../in_receipt_of_guarantee_credit.yaml | 151 +++++++ ...d_childcare_guarantee_credit_properties.py | 386 ++++++++++++++++++ 7 files changed, 858 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1956.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_guarantee_credit.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml create mode 100644 policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py diff --git a/changelog.d/1956.fixed.md b/changelog.d/1956.fixed.md new file mode 100644 index 0000000000..329e38a5d9 --- /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 working-age families and non-claimants no longer qualify through the guarantee credit computed for every benefit unit. diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index 3b41a8f8a5..07961b9d72 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", + "Pension Credit counts only when the family receives a guarantee credit: every adult has reached State Pension age, the family claims Pension Credit, and its guarantee credit is positive. The regulations require the parent to be entitled to the guaranteed element of State Pension Credit, and entitlement needs the qualifying age ([State Pension Credit Act 2002 s.1(2)](https://www.legislation.gov.uk/ukpga/2002/16/section/1)) and a claim. A working-age family therefore never qualifies through Pension Credit, even when its income is below the minimum guarantee.\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 7b0297e3ce..d5813fc8b9 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,8 +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. +description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. A parent qualifies through Pension Credit only when entitled to its guaranteed element, so that entry is receipt of a guarantee credit rather than the amount computed for every benefit unit. 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 @@ -11,4 +15,4 @@ values: - income_support - jsa_income - 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..382dcce94a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement_guarantee_credit.yaml @@ -0,0 +1,306 @@ +# 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 and not the guarantee credit that +# PolicyEngine computes for every benefit unit. +# 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%; State Pension age 66. + +- 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: + # 12,376 + 3,638.96 = 16,014.96 is computed, but a 30-year-old has not + # attained the qualifying age, so no Pension Credit is paid. + guarantee_credit: 16_014.96 + 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: + # 18,889 + 3,638.96 = 22,527.96 is computed and not paid. + guarantee_credit: 22_527.96 + 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 one year 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: + guarantee_credit: 16_014.96 + is_pension_credit_eligible: false + in_receipt_of_guarantee_credit: false + targeted_childcare_entitlement_eligible: false + is_child_receiving_targeted_childcare: [false, false] + +- name: Case 4, parent at the qualifying age with a guarantee credit and a 2-year-old qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + 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 person who reached State Pension age after April + # 2016 cannot get Savings Credit. + 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 does not qualify, because PolicyEngine does not model the SI 2019/37 art 4 saving. + 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: + # 18,889 + 3,638.96 - 10,000 = 12,527.96 is computed and not paid. + # Since 15 May 2019 a mixed-age couple cannot newly get Pension Credit; + # in law a couple saved by SI 2019/37 art 4 may still be entitled, and + # PolicyEngine's is_pension_credit_eligible omits that saving. + guarantee_credit: 12_527.96 + 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] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml new file mode 100644 index 0000000000..fe4b686a9e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml @@ -0,0 +1,151 @@ +# Receipt of a guarantee credit (the Housing Benefit passport test in +# SI 2006/214 reg 26; NI: SR 2006/406 reg 24). A guarantee credit is received +# only when the benefit unit is paid Pension Credit and that award includes a +# guarantee credit. +# 2026 parameters: standard minimum guarantee £238.00 a week single and +# £363.25 couple (12,376 and 18,889 a year); Savings Credit threshold £208.07 +# a week single (10,819.64 a year), phase-in 60%, phase-out 40%. + +- name: Case 1, single pensioner claiming Pension Credit receives a guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 10_000 + benunits: + benunit: + members: [person1] + would_claim_pc: true + households: + household: + members: [person1] + output: + # 12,376 - 10,000 = 2,376. + guarantee_credit: 2_376 + pension_credit: 2_376 + in_receipt_of_guarantee_credit: true + +- name: Case 2, the same pensioner not claiming Pension Credit receives no guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 10_000 + benunits: + benunit: + members: [person1] + would_claim_pc: false + households: + household: + members: [person1] + output: + # The guarantee credit is still computed, but nothing is paid. + guarantee_credit: 2_376 + pension_credit: 0 + in_receipt_of_guarantee_credit: false + +- name: Case 3, mixed-age couple receives no guarantee credit, because PolicyEngine does not model the SI 2019/37 art 4 saving. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 10_000 + person2: + age: 60 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + households: + household: + members: [person1, person2] + output: + # PolicyEngine's is_pension_credit_eligible omits the SI 2019/37 art 4 + # saving, so no Pension Credit is paid. In law a mixed-age couple saved + # by art 4 may be entitled to it. The computed guarantee credit is not + # asserted, so the case does not depend on whether it is computed for a + # family PolicyEngine treats as ineligible. + is_pension_credit_eligible: false + pension_credit: 0 + in_receipt_of_guarantee_credit: false + +- name: Case 4, Savings Credit only is not receipt of a guarantee credit. + 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: 12_480 + benunits: + benunit: + members: [person1] + would_claim_pc: true + households: + household: + members: [person1] + output: + # Income 12,480 (240 a week, below the personal allowance, so no income + # tax) exceeds the minimum guarantee of 12,376, so no guarantee credit. + # Savings Credit: maximum 0.6 x (12,376 - 10,819.64) = 933.816; phase-in + # 0.6 x (12,480 - 10,819.64) = 996.216, capped at 933.816; less + # 0.4 x (12,480 - 12,376) = 41.60; = 892.216. + income_tax: 0 + guarantee_credit: 0 + savings_credit: 892.22 + pension_credit: 892.22 + in_receipt_of_guarantee_credit: false + +- name: Case 5, pensioner couple claiming Pension Credit receives a guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + state_pension_reported: 5_000 + person2: + age: 68 + state_pension_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_pc: true + households: + household: + members: [person1, person2] + output: + # 18,889 - 10,000 = 8,889. + guarantee_credit: 8_889 + pension_credit: 8_889 + in_receipt_of_guarantee_credit: true + +- name: Case 6, pensioner with income above the minimum guarantee receives no guarantee credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension_reported: 12_500 + benunits: + benunit: + members: [person1] + would_claim_pc: true + households: + household: + members: [person1] + output: + # 12,500 exceeds 12,376, and a person who reached State Pension age after + # April 2016 cannot get Savings Credit. + guarantee_credit: 0 + pension_credit: 0 + in_receipt_of_guarantee_credit: false 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..81b5588e1d --- /dev/null +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -0,0 +1,386 @@ +"""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. The route follows +in_receipt_of_guarantee_credit: Pension Credit is paid and includes a +guarantee credit. + +Invariants, for any generated population of families with a 2-year-old: + +1. Qualifying age: no benefit unit whose adults are all below the qualifying + age is eligible through the Guarantee Credit route, however low its income + and however large the guarantee credit computed for it. +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: the Guarantee Credit route is open exactly when pension_credit + and guarantee_credit are both positive, so it implies would_claim_pc and + that every adult in the family is over State Pension age. The last part + is stricter than the law for a mixed-age couple saved by SI 2019/37 art 4, + which is_pension_credit_eligible omits (known departure). +4. Differential: against the pre-fix formula (route open on a computed + guarantee credit), the fix never adds eligibility, and removes it exactly + where a computed guarantee credit that is not paid was the only open + route (intended). +5. Metamorphic: not claiming Pension Credit closes the route for everyone + and never makes a family eligible. +6. Under the Pension Credit freeze the route follows the baseline receipt, so + a working-age family still does not qualify. +""" + +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 + +# 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 66 in every test year. 67 and over is unambiguously at +# the qualifying age and 65 and under unambiguously below it. +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], +} +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))) + return dict( + ages=[draw(age) for age in SHAPES[shape]], + 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()), + legacy_benefit=draw(st.sampled_from([None, None, None] + LEGACY_BENEFITS)), + ) + + +def family(ages, **overrides): + unit = dict( + ages=ages, + region="LONDON", + earnings=0.0, + state_pension=0.0, + private_pension=0.0, + savings=0.0, + would_claim_pc=True, + would_claim_uc=False, + legacy_benefit=None, + ) + unit.update(overrides) + return unit + + +# One family for each side of every rule: the working-age and non-claiming +# families have a computed guarantee credit that is not paid. +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"), +] +POPULATIONS = st.tuples( + st.sampled_from(YEARS), st.lists(families(), min_size=1, max_size=30) +) + + +def situation(year, units, would_claim_pc=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}} + 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) + child = f"c{i}" + people[child] = {"age": {year: 2}} + names.append(child) + claims = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {year: claims}, + "would_claim_uc": {year: unit["would_claim_uc"]}, + } + households[f"h{i}"] = { + "members": names, + "region": {year: unit["region"]}, + "savings": {year: unit["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +class in_receipt_of_guarantee_credit(Variable): + # The route on main before this fix: a computed guarantee credit, which + # is positive for working-age families and for non-claimants. + value_type = bool + entity = BenUnit + definition_period = YEAR_PERIOD + label = "computed guarantee credit (pre-fix route)" + + def formula(benunit, period, parameters): + return benunit("guarantee_credit", period) > 0 + + +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", + "jsa_income", + "esa_income", +] + + +def calculate(year, units, old_formula=False, reform=None, **kwargs): + sim = Simulation(situation=situation(year, units, **kwargs), reform=reform) + if old_formula: + sim.tax_benefit_system.update_variable(in_receipt_of_guarantee_credit) + 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["jsa_income"] + values["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 [ + "targeted_childcare_entitlement_eligible", + "would_claim_targeted_childcare", + "extended_childcare_entitlement_eligible", + "in_receipt_of_guarantee_credit", + "is_pension_credit_eligible", + "would_claim_pc", + ]: + values[name] = values[name].astype(bool) + return values + + +def all_adults_below_qualifying_age(unit): + return all(age <= 65 for age in unit["ages"]) + + +def all_adults_over_sp_age(unit): + return all(age >= 67 for age in unit["ages"]) + + +@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]) + eligible = values["targeted_childcare_entitlement_eligible"] + other_route = values["legacy"] | values["criteria"] + # 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(below & eligible & ~other_route) + if units is KNOWN_FAMILIES: + # Not vacuous: families below the qualifying age do have a computed + # guarantee credit, and it does not make them eligible. + computed = below & (values["guarantee_credit"] > 0) & ~other_route + assert computed.sum() >= 3 + assert not np.any(computed & eligible) + + +@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 all_adults_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) + if units is KNOWN_FAMILIES: + assert receipt.tolist() == [ + False, + False, + False, + False, + True, + False, + False, + False, + True, + False, + ] + 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. + ] + + +@PROPERTY_SETTINGS +@given(POPULATIONS) +@example((2026, KNOWN_FAMILIES)) +@example((2025, KNOWN_FAMILIES)) +def test_fix_only_removes_the_unpaid_computed_guarantee_credit_route(population): + year, units = population + new = calculate(year, units) + old = calculate(year, units, old_formula=True) + 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) + # Where Pension Credit is claimed by an eligible family, 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: + assert removed.tolist() == [ + True, # Working-age lone parent. + True, # Working-age couple. + True, # One year below the qualifying age. + False, + False, + True, # Pension-age parent not claiming Pension Credit. + False, + True, # Mixed-age couple (see the module docstring). + False, + False, + ] + + +@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( + not_claiming["targeted_childcare_entitlement_eligible"] + & ~(not_claiming["legacy"] | not_claiming["criteria"]) + ) + + +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, + ] From 95ece6c030c68234c994f9d30baf9ce714321ddf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:44:34 -0400 Subject: [PATCH 2/6] Keep the Guarantee Credit route tests off the State Pension age phase-in Co-Authored-By: Claude Opus 5.5 --- ...argeted_childcare_entitlement_guarantee_credit.yaml | 10 ++++++---- ...t_targeted_childcare_guarantee_credit_properties.py | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) 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 index 382dcce94a..ccc8199836 100644 --- 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 @@ -9,7 +9,9 @@ # 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%; State Pension age 66. +# (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 @@ -64,7 +66,7 @@ targeted_childcare_entitlement_eligible: false is_child_receiving_targeted_childcare: [false, false, false] -- name: Case 3, parent one year below the qualifying age does not qualify through a computed guarantee credit. +- 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: @@ -87,13 +89,13 @@ targeted_childcare_entitlement_eligible: false is_child_receiving_targeted_childcare: [false, false] -- name: Case 4, parent at the qualifying age with a guarantee credit and a 2-year-old qualifies. +- 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: 66 + age: 67 person2: age: 2 benunits: diff --git a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py index 81b5588e1d..cc848eeddb 100644 --- a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -51,8 +51,10 @@ ) OUTSIDE_ENGLAND = ["WALES", "SCOTLAND", "NORTHERN_IRELAND"] REGIONS = ["LONDON", "NORTH_EAST", "SOUTH_WEST"] + OUTSIDE_ENGLAND -# State Pension age is 66 in every test year. 67 and over is unambiguously at -# the qualifying age and 65 and under unambiguously below it. +# 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 = { From 2f93939b5f761c2fe3426dbd6bec0815868b61b3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:34:14 -0400 Subject: [PATCH 3/6] Require a Pension Credit payment for guarantee credit receipt; align tests with main Main (#1896) now pays Guarantee Credit only within Pension Credit, so the working-age half of #1956 is already fixed there. The targeted childcare route still needs a claim: this branch keys it on in_receipt_of_guarantee_credit, which #1909 put on main. - in_receipt_of_guarantee_credit also requires pension_credit > 0, so a reform that stops Pension Credit being paid ends receipt (review finding: neutralising pension_credit left receipt true). No change in the unreformed model, where a positive guarantee credit for an eligible claimant is always paid. - YAML: below-age cases now assert the minimum guarantee and a zero guarantee credit; add the 17/18-year-old dependant pair. - Properties: compare the route as filed, main's route and the fix; add dependent young people and childcare take-up to the generator, a payment test and a mutation check that the earlier routes break the invariants. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1956.fixed.md | 2 +- .../dfe/targeted-childcare-entitlement.ipynb | 2 +- .../qualifying_benefits.yaml | 2 +- ...hildcare_entitlement_guarantee_credit.yaml | 112 +++++++- .../in_receipt_of_guarantee_credit.yaml | 8 +- ...d_childcare_guarantee_credit_properties.py | 272 +++++++++++++----- .../in_receipt_of_guarantee_credit.py | 23 +- 7 files changed, 314 insertions(+), 107 deletions(-) diff --git a/changelog.d/1956.fixed.md b/changelog.d/1956.fixed.md index 329e38a5d9..7c9695e5ba 100644 --- a/changelog.d/1956.fixed.md +++ b/changelog.d/1956.fixed.md @@ -1 +1 @@ -- Targeted (2-year-old) childcare now qualifies through Pension Credit only when the family receives a guarantee credit, so working-age families and non-claimants no longer qualify through the guarantee credit computed for every benefit unit. +- 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 07961b9d72..1991738dd6 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -26,7 +26,7 @@ "* **Income-related Employment and Support Allowance (ESA)**\n", "* **Pension Credit (Guarantee Credit)**\n", "\n", - "Pension Credit counts only when the family receives a guarantee credit: every adult has reached State Pension age, the family claims Pension Credit, and its guarantee credit is positive. The regulations require the parent to be entitled to the guaranteed element of State Pension Credit, and entitlement needs the qualifying age ([State Pension Credit Act 2002 s.1(2)](https://www.legislation.gov.uk/ukpga/2002/16/section/1)) and a claim. A working-age family therefore never qualifies through Pension Credit, even when its income is below the minimum guarantee.\n", + "Pension Credit counts only when the family receives a guarantee credit: the claimant and any partner have reached State Pension age, the family claims Pension Credit and is paid it, and its guarantee credit is positive. The regulations require the parent to be entitled to the guaranteed element of State Pension Credit, and entitlement needs the qualifying age ([State Pension Credit Act 2002 s.1(2)](https://www.legislation.gov.uk/ukpga/2002/16/section/1)) and a claim. A working-age family therefore never qualifies through Pension Credit, even when its income is below the minimum guarantee, and nor does a pensioner who does not claim it.\n", "\n", "#### Eligibility conditions\n", "Families may also qualify based on income thresholds if they receive:\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 d5813fc8b9..66139d6ac4 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,4 +1,4 @@ -description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. A parent qualifies through Pension Credit only when entitled to its guaranteed element, so that entry is receipt of a guarantee credit rather than the amount computed for every benefit unit. +description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. A parent qualifies through Pension Credit only when entitled to its guaranteed element, which needs the qualifying age and a claim, so that entry is receipt of a guarantee credit 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" 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 index ccc8199836..5f2191de9e 100644 --- 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 @@ -4,8 +4,11 @@ # 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 and not the guarantee credit that -# PolicyEngine computes for every benefit unit. +# 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 @@ -30,9 +33,12 @@ household: members: [person1, person2] output: - # 12,376 + 3,638.96 = 16,014.96 is computed, but a 30-year-old has not - # attained the qualifying age, so no Pension Credit is paid. - guarantee_credit: 16_014.96 + # 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 @@ -58,8 +64,11 @@ household: members: [person1, person2, person3] output: - # 18,889 + 3,638.96 = 22,527.96 is computed and not paid. - guarantee_credit: 22_527.96 + # 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 @@ -83,8 +92,11 @@ household: members: [person1, person2] output: - guarantee_credit: 16_014.96 + 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] @@ -247,11 +259,14 @@ household: members: [person1, person2, person3] output: - # 18,889 + 3,638.96 - 10,000 = 12,527.96 is computed and not paid. - # Since 15 May 2019 a mixed-age couple cannot newly get Pension Credit; - # in law a couple saved by SI 2019/37 art 4 may still be entitled, and - # PolicyEngine's is_pension_credit_eligible omits that saving. - guarantee_credit: 12_527.96 + # Minimum guarantee 18,889 + 3,638.96 = 22,527.96, above income of + # 10,000. Since 15 May 2019 a mixed-age couple cannot newly get Pension + # Credit; in law a couple saved by SI 2019/37 art 4 may still be + # entitled, and PolicyEngine's is_pension_credit_eligible omits that + # saving. + 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 @@ -306,3 +321,74 @@ 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] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml index fe4b686a9e..94fde416ba 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml @@ -1,7 +1,7 @@ -# Receipt of a guarantee credit (the Housing Benefit passport test in -# SI 2006/214 reg 26; NI: SR 2006/406 reg 24). A guarantee credit is received -# only when the benefit unit is paid Pension Credit and that award includes a -# guarantee credit. +# Receipt of a guarantee credit, read by the pensioner council tax reduction +# guarantee credit rules and the targeted (2-year-old) childcare route. A +# guarantee credit is received only when the benefit unit is paid Pension +# Credit and that award includes a guarantee credit. # 2026 parameters: standard minimum guarantee £238.00 a week single and # £363.25 couple (12,376 and 18,889 a year); Savings Credit threshold £208.07 # a week single (10,819.64 a year), phase-in 60%, phase-out 40%. diff --git a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py index cc848eeddb..18dc625143 100644 --- a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -4,33 +4,47 @@ 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. The route follows -in_receipt_of_guarantee_credit: Pension Credit is paid and includes a -guarantee credit. +age" (SPCA 2002 s.1(2)(b)), and entitlement needs a claim (SSAA 1992 s.1). -Invariants, for any generated population of families with a 2-year-old: +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 is eligible through the Guarantee Credit route, however low its income - and however large the guarantee credit computed for it. + 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: the Guarantee Credit route is open exactly when pension_credit - and guarantee_credit are both positive, so it implies would_claim_pc and - that every adult in the family is over State Pension age. The last part - is stricter than the law for a mixed-age couple saved by SI 2019/37 art 4, +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. That is + stricter than the law for a mixed-age couple saved by SI 2019/37 art 4, which is_pension_credit_eligible omits (known departure). -4. Differential: against the pre-fix formula (route open on a computed - guarantee credit), the fix never adds eligibility, and removes it exactly - where a computed guarantee credit that is not paid was the only open - route (intended). -5. Metamorphic: not claiming Pension Credit closes the route for everyone - and never makes a family eligible. -6. Under the Pension Credit freeze the route follows the baseline receipt, so - a working-age family still does not qualify. +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. +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. """ import numpy as np @@ -40,6 +54,7 @@ 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] @@ -64,6 +79,10 @@ "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", @@ -75,8 +94,14 @@ @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=[draw(age) for age in SHAPES[shape]], + 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)), @@ -84,6 +109,7 @@ def families(draw): 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)), ) @@ -91,6 +117,7 @@ def families(draw): def family(ages, **overrides): unit = dict( ages=ages, + young_person=None, region="LONDON", earnings=0.0, state_pension=0.0, @@ -98,14 +125,14 @@ def family(ages, **overrides): 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: the working-age and non-claiming -# families have a computed guarantee credit that is not paid. +# One family for each side of every rule. KNOWN_FAMILIES = [ family([30]), family([30, 32]), @@ -117,19 +144,23 @@ def family(ages, **overrides): 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): +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}} + 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"]} @@ -139,14 +170,27 @@ def situation(year, units, would_claim_pc=None): 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 = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc + 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}, + "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, @@ -156,17 +200,36 @@ def situation(year, units, would_claim_pc=None): return {"people": people, "benunits": benunits, "households": households} -class in_receipt_of_guarantee_credit(Variable): - # The route on main before this fix: a computed guarantee credit, which - # is positive for working-age families and for non-claimants. - value_type = bool - entity = BenUnit - definition_period = YEAR_PERIOD - label = "computed guarantee credit (pre-fix route)" +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, + ), + ) - def formula(benunit, period, parameters): - return benunit("guarantee_credit", period) > 0 +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", @@ -181,12 +244,22 @@ def formula(benunit, period, parameters): "jsa_income", "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, old_formula=False, reform=None, **kwargs): - sim = Simulation(situation=situation(year, units, **kwargs), reform=reform) - if old_formula: - sim.tax_benefit_system.update_variable(in_receipt_of_guarantee_credit) +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 @@ -202,26 +275,37 @@ def calculate(year, units, old_formula=False, reform=None, **kwargs): values["any_over_sp_age"] = ( np.asarray(sim.calculate("is_SP_age", year, map_to="benunit")) > 0 ) - for name in [ - "targeted_childcare_entitlement_eligible", - "would_claim_targeted_childcare", - "extended_childcare_entitlement_eligible", - "in_receipt_of_guarantee_credit", - "is_pension_credit_eligible", - "would_claim_pc", - ]: + for name in BOOLEANS: values[name] = values[name].astype(bool) return values def all_adults_below_qualifying_age(unit): - return all(age <= 65 for age in unit["ages"]) + # 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 all_adults_over_sp_age(unit): +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)) @@ -232,19 +316,11 @@ def test_no_family_below_the_qualifying_age_qualifies_through_guarantee_credit( year, units = population values = calculate(year, units) 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"] # 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(below & eligible & ~other_route) - if units is KNOWN_FAMILIES: - # Not vacuous: families below the qualifying age do have a computed - # guarantee credit, and it does not make them eligible. - computed = below & (values["guarantee_credit"] > 0) & ~other_route - assert computed.sum() >= 3 - assert not np.any(computed & eligible) + assert not np.any(qualifying_age_violations(units, values)) @PROPERTY_SETTINGS @@ -261,7 +337,7 @@ def test_eligibility_is_the_union_of_the_routes(population): assert not np.any(receipt & ~values["would_claim_pc"]) for i, unit in enumerate(units): if receipt[i]: - assert all_adults_over_sp_age(unit), unit + assert claimant_and_partner_over_sp_age(unit), unit expected = ( values["would_claim_targeted_childcare"] & values["in_england"] @@ -269,6 +345,11 @@ def test_eligibility_is_the_union_of_the_routes(population): & (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, @@ -276,11 +357,13 @@ def test_eligibility_is_the_union_of_the_routes(population): False, False, True, - False, + 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, @@ -293,6 +376,8 @@ def test_eligibility_is_the_union_of_the_routes(population): 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. ] @@ -300,10 +385,10 @@ def test_eligibility_is_the_union_of_the_routes(population): @given(POPULATIONS) @example((2026, KNOWN_FAMILIES)) @example((2025, KNOWN_FAMILIES)) -def test_fix_only_removes_the_unpaid_computed_guarantee_credit_route(population): +def test_fix_only_removes_the_unpaid_guarantee_credit_route(population): year, units = population new = calculate(year, units) - old = calculate(year, units, old_formula=True) + 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. @@ -318,22 +403,17 @@ def test_fix_only_removes_the_unpaid_computed_guarantee_credit_route(population) 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) - # Where Pension Credit is claimed by an eligible family, nothing changes. + # 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: - assert removed.tolist() == [ - True, # Working-age lone parent. - True, # Working-age couple. - True, # One year below the qualifying age. - False, - False, - True, # Pension-age parent not claiming Pension Credit. - False, - True, # Mixed-age couple (see the module docstring). - False, - False, - ] + expected = [False] * len(KNOWN_FAMILIES) + expected[NON_CLAIMANT] = True + assert removed.tolist() == expected @PROPERTY_SETTINGS @@ -349,10 +429,48 @@ def test_not_claiming_pension_credit_never_makes_a_family_eligible(population): & ~claiming["targeted_childcare_entitlement_eligible"] ) # Without a Pension Credit claim only the other routes are left. - assert not np.any( - not_claiming["targeted_childcare_entitlement_eligible"] - & ~(not_claiming["legacy"] | not_claiming["criteria"]) + 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] def test_pension_credit_freeze_keeps_the_baseline_route(): 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 877ab3606b..a3d2a076cf 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. The model's Pension Credit eligibility " - "omits the SI 2019/37 article 4 saving, so a mixed-age couple it saves " - "(entitled to Pension Credit or Housing Benefit as that couple on 14 " - "May 2019, and to either ever since) is not treated as in receipt. 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. The " + "model's Pension Credit eligibility omits the SI 2019/37 article 4 " + "saving, so a mixed-age couple it saves (entitled to Pension Credit or " + "Housing Benefit as that couple on 14 May 2019, and to either ever " + "since) is not treated as in receipt. Under the Pension Credit freeze " + "the baseline receipt is kept, because the frozen award is the " + "baseline award." ) entity = BenUnit definition_period = YEAR @@ -36,4 +38,5 @@ def formula(benunit, period, parameters): 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) + paid = benunit("pension_credit", period) > 0 + return eligible & would_claim & paid & (guarantee_credit > 0) From 314f457ae09ea333cf7ed3389bf8911eb1925fa9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 16:13:17 -0400 Subject: [PATCH 4/6] State the targeted childcare Pension Credit route as the model's rule; fix saving and Savings Credit wording Law review: the docs now separate the legal rule (entitlement: qualifying age, SPCA s.4(1A) with the SI 2019/37 art 4 saving, a claim) from PolicyEngine's receipt test; the art 4 saving is described as art 4 states it; the Savings Credit comment follows SPCA s.3(1). The receipt YAML test is restored to #1927's blob (fe4b686) so the two PRs do not conflict on it. Co-Authored-By: Claude Opus 5.5 --- .../gov/dfe/targeted-childcare-entitlement.ipynb | 2 +- ...geted_childcare_entitlement_guarantee_credit.yaml | 12 ++++++------ .../in_receipt_of_guarantee_credit.yaml | 8 ++++---- .../in_receipt_of_guarantee_credit.py | 11 ++++++----- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index 1991738dd6..ae91d763fe 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -26,7 +26,7 @@ "* **Income-related Employment and Support Allowance (ESA)**\n", "* **Pension Credit (Guarantee Credit)**\n", "\n", - "Pension Credit counts only when the family receives a guarantee credit: the claimant and any partner have reached State Pension age, the family claims Pension Credit and is paid it, and its guarantee credit is positive. The regulations require the parent to be entitled to the guaranteed element of State Pension Credit, and entitlement needs the qualifying age ([State Pension Credit Act 2002 s.1(2)](https://www.legislation.gov.uk/ukpga/2002/16/section/1)) and a claim. A working-age family therefore never qualifies through Pension Credit, even when its income is below the minimum guarantee, and nor does a pensioner who does not claim it.\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 and any partner have reached State Pension age, 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. The model does not apply the SI 2019/37 art 4 saving for mixed-age couples, and it uses payment in place of entitlement, which differs only 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", 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 index 5f2191de9e..d4235a2a20 100644 --- 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 @@ -197,8 +197,8 @@ members: [person1, person2] output: # Income tax (20,000 - 12,570) x 20% = 1,486, so income is 18,514, - # above 16,014.96. A person who reached State Pension age after April - # 2016 cannot get Savings Credit. + # 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 @@ -260,10 +260,10 @@ members: [person1, person2, person3] output: # Minimum guarantee 18,889 + 3,638.96 = 22,527.96, above income of - # 10,000. Since 15 May 2019 a mixed-age couple cannot newly get Pension - # Credit; in law a couple saved by SI 2019/37 art 4 may still be - # entitled, and PolicyEngine's is_pension_credit_eligible omits that - # saving. + # 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); PolicyEngine's + # is_pension_credit_eligible omits that saving. minimum_guarantee: 22_527.96 is_guarantee_credit_eligible: true guarantee_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml index 94fde416ba..fe4b686a9e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.yaml @@ -1,7 +1,7 @@ -# Receipt of a guarantee credit, read by the pensioner council tax reduction -# guarantee credit rules and the targeted (2-year-old) childcare route. A -# guarantee credit is received only when the benefit unit is paid Pension -# Credit and that award includes a guarantee credit. +# Receipt of a guarantee credit (the Housing Benefit passport test in +# SI 2006/214 reg 26; NI: SR 2006/406 reg 24). A guarantee credit is received +# only when the benefit unit is paid Pension Credit and that award includes a +# guarantee credit. # 2026 parameters: standard minimum guarantee £238.00 a week single and # £363.25 couple (12,376 and 18,889 a year); Savings Credit threshold £208.07 # a week single (10,819.64 a year), phase-in 60%, phase-out 40%. 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 a3d2a076cf..1d9828aab0 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 @@ -12,11 +12,12 @@ class in_receipt_of_guarantee_credit(Variable): "for it (for example a working-age family), is not received, and " "nor is one under a reform that stops Pension Credit being paid. The " "model's Pension Credit eligibility omits the SI 2019/37 article 4 " - "saving, so a mixed-age couple it saves (entitled to Pension Credit or " - "Housing Benefit as that couple on 14 May 2019, and to either ever " - "since) is not treated as in receipt. Under the Pension Credit freeze " - "the baseline receipt is kept, because the frozen award is the " - "baseline award." + "saving, so a member of a mixed-age couple it saves (entitled to " + "Pension Credit or Housing Benefit as that couple on 14 May 2019, " + "until a later day when entitled to neither as that couple, with " + "Universal Credit days after managed migration disregarded) is not " + "treated as in receipt. Under the Pension Credit freeze the baseline " + "receipt is kept, because the frozen award is the baseline award." ) entity = BenUnit definition_period = YEAR From c47e2837a372ded98c11b7abfd0ea97b41a4a391 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 16:26:28 -0400 Subject: [PATCH 5/6] Check the Pension Credit payment under the freeze too; state the payment departure plainly Independent review: with gov.contrib.freeze_pension_credit on, the receipt returned the baseline receipt without checking the payment, so a reform that also stopped Pension Credit left receipt true. The freeze branch now requires pension_credit > 0 as well; nothing changes while the frozen award is paid. The docs say the model does not distinguish entitlement from payment instead of claiming they differ only at the 10p rule. Co-Authored-By: Claude Opus 5.5 --- .../dfe/targeted-childcare-entitlement.ipynb | 2 +- ...d_childcare_guarantee_credit_properties.py | 23 +++++++++++++++++-- .../in_receipt_of_guarantee_credit.py | 7 +++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index ae91d763fe..28645dd467 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -26,7 +26,7 @@ "* **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 and any partner have reached State Pension age, 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. The model does not apply the SI 2019/37 art 4 saving for mixed-age couples, and it uses payment in place of entitlement, which differs only 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", + "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 and any partner have reached State Pension age, 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. The model does not apply the SI 2019/37 art 4 saving for mixed-age couples, and it 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", diff --git a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py index 18dc625143..f2fb30f015 100644 --- a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -41,10 +41,11 @@ 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. +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. + are stated for the unreformed model only (fixed example). """ import numpy as np @@ -472,6 +473,24 @@ def test_stopping_pension_credit_payments_closes_the_route(): 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 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 1d9828aab0..c6808dd71b 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 @@ -17,7 +17,8 @@ class in_receipt_of_guarantee_credit(Variable): "until a later day when entitled to neither as that couple, with " "Universal Credit days after managed migration disregarded) is not " "treated as in receipt. Under the Pension Credit freeze the baseline " - "receipt is kept, because the frozen award is the baseline award." + "receipt is kept while Pension Credit is still paid, because the " + "frozen award is the baseline award." ) entity = BenUnit definition_period = YEAR @@ -30,14 +31,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) - paid = benunit("pension_credit", period) > 0 return eligible & would_claim & paid & (guarantee_credit > 0) From b31626fcb4232283facf7ffdde9af9abb3362713 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 07:23:37 -0400 Subject: [PATCH 6/6] Age the saved mixed-age couple for the SI 2019/37 saving; name the saving in the docs Address the merge-delta review's nits: - YAML Case 14: the older member is 80, so had reached the qualifying age by 14 May 2019 as SI 2019/37 art 4 requires. The award is unchanged. - The notebook's age requirement now names the art 4 exception. Point the property test's legacy-benefit oracle at claimant_or_partner_jsa_income and claimant_or_partner_esa_income, the entries the parameter lists since #2027. Co-Authored-By: Claude Opus 5.5 --- .../gov/dfe/targeted-childcare-entitlement.ipynb | 2 +- ...rgeted_childcare_entitlement_guarantee_credit.yaml | 11 +++++++---- ..._targeted_childcare_guarantee_credit_properties.py | 8 +++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index a94c545fa3..4b3e3e6ef7 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -26,7 +26,7 @@ "* **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 and any partner have reached State Pension age, 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", + "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", 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 index e5054d6ad1..01ca6e6035 100644 --- 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 @@ -400,7 +400,7 @@ input: people: person1: - age: 70 + age: 80 state_pension_reported: 10_000 person2: age: 40 @@ -416,9 +416,12 @@ household: members: [person1, person2, person3] output: - # The Case 9 couple, saved by SI 2019/37 art 4, so SPCA s.4(1A) does not - # apply. Couple minimum guarantee 18,889 + 3,638.96 = 22,527.96, less - # the State Pension of 10,000, = 12,527.96. + # 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 diff --git a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py index 67da6d0645..af04dedcee 100644 --- a/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py +++ b/policyengine_uk/tests/test_targeted_childcare_guarantee_credit_properties.py @@ -243,8 +243,8 @@ def route(label, formula): "is_pension_credit_eligible", "would_claim_pc", "income_support", - "jsa_income", - "esa_income", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", ] BOOLEANS = [ "targeted_childcare_entitlement_eligible", @@ -269,7 +269,9 @@ def calculate(year, units, version="fix", reform=None, scenario=None, **kwargs): sum(np.asarray(sim.calculate(v, year)).astype(int) for v in criteria) > 0 ) values["legacy"] = ( - values["income_support"] + values["jsa_income"] + values["esa_income"] + 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]