From df94b5f8a0d62a5999b9f1a36ec44cefab0e41e9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 06:44:04 -0400 Subject: [PATCH 1/9] Apply the 6 April 2017 date-of-birth cutoffs exactly The Universal Credit child limit and higher first child element, the Child Tax Credit child limit and the Pension Credit first child addition turn on whether a child was born before 6 April 2017. They compared birth_year (period less whole age) with 2017, which in survey data treats the whole cohort born October 2016 to October 2017 as born after the cutoff. Add date_of_birth (YYYYMMDD) from age and months_since_last_birthday, which state_pension_age now reads too; compare it with new born_before date parameters; order UC and Pension Credit children by date of birth; derive birth_year from it. Household situations are unchanged. Co-Authored-By: Claude Opus 5.5 --- changelog.d/date-of-birth-cutoffs.added.md | 1 + changelog.d/date-of-birth-cutoffs.changed.md | 1 + changelog.d/date-of-birth-cutoffs.fixed.md | 1 + changelog.d/date-of-birth-cutoffs.removed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 5 +- docs/book/programs/gov/dwp/tax-credits.md | 11 +- .../child/first/born_before.yaml | 10 + .../child_tax_credit/limit/born_before.yaml | 12 + .../child_tax_credit/limit/start_year.yaml | 6 - .../elements/child/limit/born_before.yaml | 12 + .../elements/child/limit/start_year.yaml | 10 - .../baseline/demographic/date_of_birth.yaml | 75 ++++++ .../finance/benefit/family/tax_credits.yaml | 60 +++++ .../uc_is_child_born_before_child_limit.yaml | 127 ++++++++++ .../child_minimum_guarantee_addition.yaml | 57 ++++- .../two_child_limit/ctc_age_exemption.yaml | 20 -- policyengine_uk/tests/test_date_of_birth.py | 239 ++++++++++++++++++ .../gov/dwp/is_CTC_child_limit_exempt.py | 16 +- .../child_minimum_guarantee_addition.py | 13 +- .../variables/gov/dwp/state_pension_age.py | 10 +- .../child_element/uc_child_index.py | 5 +- .../uc_is_child_born_before_child_limit.py | 14 +- .../household/demographic/birth_year.py | 4 +- .../household/demographic/date_of_birth.py | 35 +++ 24 files changed, 679 insertions(+), 66 deletions(-) create mode 100644 changelog.d/date-of-birth-cutoffs.added.md create mode 100644 changelog.d/date-of-birth-cutoffs.changed.md create mode 100644 changelog.d/date-of-birth-cutoffs.fixed.md create mode 100644 changelog.d/date-of-birth-cutoffs.removed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/first/born_before.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/born_before.yaml delete mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/start_year.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/start_year.yaml create mode 100644 policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml create mode 100644 policyengine_uk/tests/test_date_of_birth.py create mode 100644 policyengine_uk/variables/household/demographic/date_of_birth.py diff --git a/changelog.d/date-of-birth-cutoffs.added.md b/changelog.d/date-of-birth-cutoffs.added.md new file mode 100644 index 0000000000..9d2dd23b2c --- /dev/null +++ b/changelog.d/date-of-birth-cutoffs.added.md @@ -0,0 +1 @@ +Add `date_of_birth`, each person's day of birth as a YYYYMMDD number from `age` and `months_since_last_birthday`, and date parameters for the 6 April 2017 cutoffs: `gov.dwp.universal_credit.elements.child.limit.born_before`, `gov.dwp.tax_credits.child_tax_credit.limit.born_before` and `gov.dwp.pension_credit.guarantee_credit.child.first.born_before`. diff --git a/changelog.d/date-of-birth-cutoffs.changed.md b/changelog.d/date-of-birth-cutoffs.changed.md new file mode 100644 index 0000000000..c6f92382e2 --- /dev/null +++ b/changelog.d/date-of-birth-cutoffs.changed.md @@ -0,0 +1 @@ +`birth_year` is now the calendar year of `date_of_birth`. It is unchanged for household situations with whole ages; in survey data, people whose birthday falls from 7 October to 31 December now have the year before the period less their age, and a fractional age no longer truncates to the year before. diff --git a/changelog.d/date-of-birth-cutoffs.fixed.md b/changelog.d/date-of-birth-cutoffs.fixed.md new file mode 100644 index 0000000000..585a5abc0a --- /dev/null +++ b/changelog.d/date-of-birth-cutoffs.fixed.md @@ -0,0 +1 @@ +Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) and the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations, which place each person in the middle of their year of age, are unchanged. diff --git a/changelog.d/date-of-birth-cutoffs.removed.md b/changelog.d/date-of-birth-cutoffs.removed.md new file mode 100644 index 0000000000..4e941f0474 --- /dev/null +++ b/changelog.d/date-of-birth-cutoffs.removed.md @@ -0,0 +1 @@ +Remove `gov.dwp.universal_credit.elements.child.limit.start_year` and `gov.dwp.tax_credits.child_tax_credit.limit.start_year`, which held the year 2017. A reform to the cutoff now sets the matching `born_before` date parameter, written as a YYYYMMDD number (for example 20170406). diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index db743051f2..0a16cd9f0a 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -52,7 +52,10 @@ timetable row by row: A person attains State Pension age on the later of the two. The model places each person's date of birth with `age` and `months_since_last_birthday`, which counts months since their last -birthday on 6 October, the middle of the fiscal year: +birthday on 6 October, the middle of the fiscal year. `date_of_birth` holds +the resulting day (as a YYYYMMDD number), which other date-of-birth rules +also read, such as the 6 April 2017 cutoffs in Universal Credit, Child Tax +Credit and Pension Credit: - `state_pension_age` is the person's own State Pension age; - `months_since_state_pension_age` is how long before 6 October they diff --git a/docs/book/programs/gov/dwp/tax-credits.md b/docs/book/programs/gov/dwp/tax-credits.md index 284dd56994..eac83f4600 100644 --- a/docs/book/programs/gov/dwp/tax-credits.md +++ b/docs/book/programs/gov/dwp/tax-credits.md @@ -68,11 +68,12 @@ benunit worked. PolicyEngine computes it via `ctc_entitlement`, gated by Since April 2017 the child element has been restricted to the first two qualifying children. The `child_count` parameter under -`child_tax_credit/limit/` and the `start_year/year.yaml` switch encode -this. Children born before the start year and a few specific exceptions -(non-consensual conception, multiple births, kinship care) are exempt; -the exemptions are modelled via `gov/contrib/two_child_limit/` and -related variables. +`child_tax_credit/limit/` encodes this, and `born_before` (6 April 2017, +Tax Credits Act 2002 s.9(3A)) sets which children it applies to: those +born before it are exempt, compared on `date_of_birth`. A few specific +exceptions (non-consensual conception, multiple births, kinship care) +also apply in law but are not modelled; `gov/contrib/two_child_limit/` +holds reform options such as an age-based exemption. ### Qualifying child tests diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/first/born_before.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/first/born_before.yaml new file mode 100644 index 0000000000..eaf4bdb59d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/first/born_before.yaml @@ -0,0 +1,10 @@ +description: The first child addition replaces the standard child addition in the Pension Credit minimum guarantee for the eldest child or qualifying young person if they were born before this date, written as a YYYYMMDD number. +values: + 0001-01-01: 20170406 +metadata: + unit: date + period: year + label: Pension Credit first child addition cutoff date of birth + reference: + - title: The State Pension Credit Regulations 2002 Schedule IIA paragraph 10 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/IIA diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/born_before.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/born_before.yaml new file mode 100644 index 0000000000..7044d46ca0 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/born_before.yaml @@ -0,0 +1,12 @@ +description: The Child Tax Credit child limit applies to children and qualifying young persons born on or after this date, written as a YYYYMMDD number; those born before it are exempt. +values: + 0001-01-01: 20170406 +metadata: + unit: date + period: year + label: Child Tax Credit child limit cutoff date of birth + reference: + - title: Tax Credits Act 2002 s. 9(3A) and (3B) + href: https://www.legislation.gov.uk/ukpga/2002/21/section/9 + - title: The Child Tax Credit Regulations 2002 reg. 9 (exceptions to the restriction on numbers) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/9 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/start_year.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/start_year.yaml deleted file mode 100644 index a59e4dd075..0000000000 --- a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/limit/start_year.yaml +++ /dev/null @@ -1,6 +0,0 @@ -description: Year in which the Child Tax Credit child limit applies. -values: - 0001-01-01: 2017 -metadata: - unit: year - reference: https://www.gov.uk/guidance/child-tax-credit-exceptions-to-the-2-child-limit diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml new file mode 100644 index 0000000000..0b56faa862 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml @@ -0,0 +1,12 @@ +description: A child or qualifying young person born before this date, written as a YYYYMMDD number, is transitionally protected from the Universal Credit child limit, and a claimant responsible for one keeps the higher child element for the first child. +values: + 0001-01-01: 20170406 +metadata: + unit: date + period: year + label: Universal Credit child limit and higher first child element cutoff date of birth + reference: + - title: The Universal Credit Regulations 2013 reg. 24A(3) (as in force from 6 April 2017 to 5 April 2026) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/24A/2025-04-06 + - title: The Universal Credit (Transitional Provisions) Regulations 2014 reg. 43 (saving of the higher first child element) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/43 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/start_year.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/start_year.yaml deleted file mode 100644 index e96d8f3e00..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/start_year.yaml +++ /dev/null @@ -1,10 +0,0 @@ -description: A higher Universal Credit child element is payable for the first child if the child is born before this year. -values: - 0001-01-01: 2017 -metadata: - unit: year - period: year - label: Universal Credit child element start year - reference: - - title: The Universal Credit Regulations 2013 reg. 24A (3) - href: https://www.legislation.gov.uk/uksi/2013/376/data.pdf#page=31 diff --git a/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml b/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml new file mode 100644 index 0000000000..7ae735b71c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml @@ -0,0 +1,75 @@ +# The date of birth is age years and months_since_last_birthday months before +# 6 October of the fiscal year, on a grid whose months start on the 6th; the +# day of birth is the one starting at or after that instant. + +- name: A whole age in a household situation is the middle of the year of age. + period: 2025 + absolute_error_margin: 0 + input: + age: 8 + output: + months_since_last_birthday: 6 + # 6 October 2025 less 8 years and 6 months. + date_of_birth: 20170406 + birth_year: 2017 + +- name: One day more since the last birthday is born the day before. + period: 2025 + absolute_error_margin: 0 + input: + age: 8 + # The grid month from 6 March to 5 April 2017 has 31 days. + months_since_last_birthday: 6.032258064516129 + output: + date_of_birth: 20170405 + birth_year: 2017 + +- name: A birthday in December falls in the year before period less age. + period: 2025 + absolute_error_margin: 0 + input: + age: 8 + months_since_last_birthday: 10 + output: + # 6 October 2025 less 8 years and 10 months. + date_of_birth: 20161206 + birth_year: 2016 + +- name: A fractional age is the exact age on 6 October. + period: 2025 + absolute_error_margin: 0 + input: + age: 8.25 + output: + months_since_last_birthday: 3 + date_of_birth: 20170706 + birth_year: 2017 + +- name: A birthday on 6 October itself. + period: 2025 + absolute_error_margin: 0 + input: + age: 0 + months_since_last_birthday: 0 + output: + date_of_birth: 20251006 + birth_year: 2025 + +- name: A date of birth from the age and birthday places the Universal Credit cutoff exactly, born 5 April 2017. + period: 2025 + absolute_error_margin: 0 + input: + age: 8 + months_since_last_birthday: 6.032258064516129 + output: + uc_is_child_born_before_child_limit: true + is_CTC_child_limit_exempt: true + +- name: A date of birth from the age and birthday places the Universal Credit cutoff exactly, born 6 April 2017. + period: 2025 + absolute_error_margin: 0 + input: + age: 8 + output: + uc_is_child_born_before_child_limit: false + is_CTC_child_limit_exempt: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits.yaml index 7725b1e703..85504d160e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits.yaml @@ -104,3 +104,63 @@ child_tax_credit_pre_minimum: 26 output: tax_credits: 26 +# Tax Credits Act 2002 s.9(3A): the child limit applies to a child or qualifying +# young person born on or after 6 April 2017. The 2024 child element is 3,455. +- name: CTC child limit, born 5 April 2017 is exempt + period: 2024 + absolute_error_margin: 0 + input: + age: 7 + date_of_birth: 20170405 + output: + is_CTC_child_limit_exempt: true +- name: CTC child limit, born 6 April 2017 is not exempt + period: 2024 + absolute_error_margin: 0 + input: + age: 7 + date_of_birth: 20170406 + output: + is_CTC_child_limit_exempt: false +- name: CTC child element for a third child born 5 April 2017 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_1: + age: 10 + date_of_birth: 20140301 + child_2: + age: 9 + date_of_birth: 20150501 + child_3: + age: 7 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + output: + CTC_child_element: 10_365 +- name: CTC child element for a third child born 6 April 2017 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_1: + age: 10 + date_of_birth: 20140301 + child_2: + age: 9 + date_of_birth: 20150501 + child_3: + age: 7 + date_of_birth: 20170406 + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + output: + CTC_child_element: 6_910 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml index 53619af66a..f312ab2d40 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml @@ -21,3 +21,130 @@ age: 4 output: uc_is_child_born_before_child_limit: true + +# UC Regs 2013 reg 24A(3) (in force 6 April 2017 to 5 April 2026): a child or +# qualifying young person born before 6 April 2017 is transitionally protected. +# In 2025 the child element is 292.81 * 12 = 3,513.72 and the higher amount for +# the first child is 339 * 12 = 4,068. + +- name: A third child born on 5 April 2017 is protected from the child limit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child_1: + age: 10 + date_of_birth: 20150301 + child_2: + age: 9 + date_of_birth: 20160501 + child_3: + age: 8 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + output: + uc_is_child_born_before_child_limit: [false, true, true, true] + uc_child_index: [-1, 1, 2, 3] + uc_individual_child_element: [0, 4_068, 3_513.72, 3_513.72] + +- name: A third child born on 6 April 2017 is not protected from the child limit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child_1: + age: 10 + date_of_birth: 20150301 + child_2: + age: 9 + date_of_birth: 20160501 + child_3: + age: 8 + date_of_birth: 20170406 + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + output: + uc_is_child_born_before_child_limit: [false, true, true, false] + uc_child_index: [-1, 1, 2, 3] + uc_individual_child_element: [0, 4_068, 3_513.72, 0] + +- name: Children of the same age are ordered by date of birth (reg 24B). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child_1: + age: 10 + date_of_birth: 20150301 + born_later: + age: 8 + date_of_birth: 20170406 + born_earlier: + age: 8 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child_1, born_later, born_earlier] + output: + # The child born on 6 April 2017 is the third child and not protected; the + # one born on 5 April is the second, and protected either way. + uc_child_index: [-1, 1, 3, 2] + uc_individual_child_element: [0, 4_068, 0, 3_513.72] + +# UC (Transitional Provisions) Regs 2014 reg 43 keeps the higher child element +# for the first child where the claimant is responsible for a child born before +# 6 April 2017. In 2026 the child element is 303.94 * 12 = 3,647.28 and the +# higher amount is 351.88 * 12 = 4,222.56. + +- name: An only child born on 5 April 2017 keeps the higher first child amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child: + age: 9 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child] + output: + uc_is_child_born_before_child_limit: [false, true] + uc_individual_child_element: [0, 4_222.56] + +- name: An only child born on 6 April 2017 gets the standard child amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child: + age: 9 + date_of_birth: 20170406 + benunits: + benunit: + members: [parent, child] + output: + uc_is_child_born_before_child_limit: [false, false] + uc_individual_child_element: [0, 3_647.28] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml index e51465b6e8..110e65a09e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_minimum_guarantee_addition.yaml @@ -7,13 +7,66 @@ age: 81 older_child: age: 10 - birth_year: 2016 younger_child: age: 6 - birth_year: 2020 benunits: benunit: members: [claimant, older_child, younger_child] output: child_minimum_guarantee_addition: 7_854.6 minimum_guarantee: 20_230.6 + +# Sch IIA para 10: the eldest child or qualifying young person gets 81.07 a week +# instead of 69.98 if born before 6 April 2017. 81.07 * 52 = 4,215.64 and +# 69.98 * 52 = 3,638.96. +- name: An only child born on 5 April 2017 gets the first child amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + child: + age: 9 + date_of_birth: 20170405 + benunits: + benunit: + members: [claimant, child] + output: + child_minimum_guarantee_addition: 4_215.64 + +- name: An only child born on 6 April 2017 gets the standard child amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + child: + age: 9 + date_of_birth: 20170406 + benunits: + benunit: + members: [claimant, child] + output: + child_minimum_guarantee_addition: 3_638.96 + +- name: The eldest of two children of the same age is the one born first. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + born_later: + age: 9 + date_of_birth: 20170406 + born_earlier: + age: 9 + date_of_birth: 20170405 + benunits: + benunit: + members: [claimant, born_later, born_earlier] + output: + # (81.07 + 69.98) * 52 = 7,854.60. + child_minimum_guarantee_addition: 7_854.60 diff --git a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml index a2c470489a..44d4df7a05 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml @@ -8,16 +8,12 @@ child_tax_credit_reported: 8000 child_1: age: 5 - birth_year: 2018 child_2: age: 4 - birth_year: 2019 child_3: age: 2 - birth_year: 2021 child_4: age: 1 - birth_year: 2022 benunits: benunit: members: [parent, child_1, child_2, child_3, child_4] @@ -36,16 +32,12 @@ child_tax_credit_reported: 8000 child_1: age: 5 - birth_year: 2018 child_2: age: 4 - birth_year: 2019 child_3: age: 2 # This child is under the threshold - birth_year: 2021 child_4: age: 1 - birth_year: 2022 benunits: benunit: members: [parent, child_1, child_2, child_3, child_4] @@ -64,16 +56,12 @@ child_tax_credit_reported: 8000 child_1: age: 5 - birth_year: 2018 child_2: age: 4 - birth_year: 2019 child_3: age: 3 # No child below threshold - birth_year: 2020 child_4: age: 3 - birth_year: 2020 benunits: benunit: members: [parent, child_1, child_2, child_3, child_4] @@ -91,16 +79,12 @@ child_tax_credit_reported: 8000 child_1: age: 5 - birth_year: 2018 child_2: age: 4 - birth_year: 2019 child_3: age: 2 # This child is under the threshold - birth_year: 2021 child_4: age: 1 # This child is under the threshold - birth_year: 2022 benunits: benunit: members: [parent, child_1, child_2, child_3, child_4] @@ -119,16 +103,12 @@ child_tax_credit_reported: 8000 child_1: age: 5 - birth_year: 2018 child_2: age: 4 - birth_year: 2019 child_3: age: 2 # This child is under the threshold - birth_year: 2021 child_4: age: 1 # This child is under the threshold - birth_year: 2022 benunits: benunit: members: [parent, child_1, child_2, child_3, child_4] diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py new file mode 100644 index 0000000000..8fac33c276 --- /dev/null +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -0,0 +1,239 @@ +"""Date-of-birth cutoffs in Universal Credit, Child Tax Credit and Pension Credit. + +UC Regs 2013 reg 24A(3), UC (Transitional Provisions) Regs 2014 reg 43, Tax +Credits Act 2002 s.9(3A) and SPC Regs 2002 Sch IIA para 10 all turn on whether a +child was born before 6 April 2017. The model compares date_of_birth with that +date. The reference here reads the date of birth with datetime, sharing no code +with policyengine_uk.utils.dates. +""" + +from datetime import date, timedelta + +import numpy as np +import pandas as pd +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +CUTOFF = date(2017, 4, 6) + + +def ymd(day: date) -> int: + return day.year * 10000 + day.month * 100 + day.day + + +def from_ymd(value: int) -> date: + value = int(value) + return date(value // 10000, value // 100 % 100, value % 100) + + +def grid_months(day: date) -> float: + """Months on a grid whose months start on the 6th.""" + whole = 12 * day.year + day.month - 1 - (day.day < 6) + start = date(whole // 12, whole % 12 + 1, 6) + end = date((whole + 1) // 12, (whole + 1) % 12 + 1, 6) + return whole + (day - start).days / (end - start).days + + +def birth_day_from_grid(months: float) -> date: + """The calendar day starting at or after an instant on the grid, within + about a minute and a half, as the model treats float error.""" + whole = int(np.floor(months)) + start = date(whole // 12, whole % 12 + 1, 6) + end = date((whole + 1) // 12, (whole + 1) % 12 + 1, 6) + offset = int(np.ceil((months - whole) * (end - start).days - 1e-3)) + return start + timedelta(days=offset) + + +def legal_age(birth: date, on: date) -> int: + return on.year - birth.year - ((on.month, on.day) < (birth.month, birth.day)) + + +def situation(people: dict) -> dict: + return { + "people": people, + "benunits": {"benunit": {"members": list(people)}}, + "households": {"household": {"members": list(people)}}, + } + + +def test_household_situations_match_the_birth_year_rule(): + """A whole age in a household situation is the middle of the year of age, + 6 April of the year the person turned that age. So for households the + date rule gives what the old birth-year rule (period - age < 2017) gave, + and birth_year is still period - age: only microdata, which spreads + birthdays over the year, moves.""" + for year in range(2015, 2036): + ages = range(20) + people = {f"p{age}": {"age": {year: age}} for age in ages} + sim = Simulation(situation=situation(people)) + born = sim.calculate("date_of_birth", year) + birth_year = sim.calculate("birth_year", year) + exempt = sim.calculate("is_CTC_child_limit_exempt", year) + old_rule = np.array([year - age < 2017 for age in ages]) + assert np.array_equal(born < ymd(CUTOFF), old_rule), year + assert np.array_equal(exempt, old_rule), year + assert np.array_equal(birth_year, [year - age for age in ages]), year + + +@settings(max_examples=30, deadline=None) +@given( + cases=st.lists( + st.tuples( + st.integers(min_value=0, max_value=15), + st.floats(min_value=0, max_value=12, exclude_max=True), + ), + min_size=1, + max_size=12, + ), + year=st.integers(min_value=2015, max_value=2040), +) +def test_cutoffs_follow_the_date_of_birth(cases, year): + """For any whole age and position in the year, including instants within a + day: date_of_birth is the day the reference places, the person has that + legal age on 6 October, birth_year is its year, and each cutoff holds + exactly when the person was born before 6 April 2017.""" + people = {"parent": {"age": {year: 40}, "months_since_last_birthday": {year: 6}}} + for i, (age, months) in enumerate(cases): + people[f"c{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + sim = Simulation(situation=situation(people)) + born = sim.calculate("date_of_birth", year)[1:] + birth_year = sim.calculate("birth_year", year)[1:] + uc = sim.calculate("uc_is_child_born_before_child_limit", year)[1:] + ctc = sim.calculate("is_CTC_child_limit_exempt", year)[1:] + mid_year = date(year, 10, 6) + for i, (age, months) in enumerate(cases): + # The model caps months since the last birthday about four minutes + # short of 12, so the exact age never rounds onto the next birthday. + months = min(months, 12 - 1e-4) + expected = birth_day_from_grid(grid_months(mid_year) - 12 * age - months) + assert from_ymd(born[i]) == expected, (year, age, months) + assert legal_age(expected, mid_year) == age, (year, age, months) + assert birth_year[i] == expected.year + assert uc[i] == (expected < CUTOFF), (year, age, months) + assert ctc[i] == (expected < CUTOFF), (year, age, months) + + +@settings(max_examples=25, deadline=None) +@given( + children=st.lists( + st.tuples( + st.integers(min_value=0, max_value=15), + st.floats(min_value=0, max_value=12, exclude_max=True), + ), + min_size=2, + max_size=6, + ), + year=st.integers(min_value=2017, max_value=2030), +) +def test_children_are_ordered_by_date_of_birth(children, year): + """Universal Credit numbers children eldest first by date of birth (reg + 24B), which refines the order by whole age: an older child always comes + first, and children of the same age come in the order they were born.""" + people = { + "parent": { + "age": {year: 40}, + "months_since_last_birthday": {year: 6}, + "is_benunit_head": {year: True}, + "is_parent": {year: True}, + } + } + for i, (age, months) in enumerate(children): + people[f"c{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + "is_benunit_head": {year: False}, + } + sim = Simulation(situation=situation(people)) + index = sim.calculate("uc_child_index", year)[1:] + born = sim.calculate("date_of_birth", year)[1:] + ages = np.array([age for age, _ in children]) + assert sorted(index) == list(range(1, len(children) + 1)) + for a in range(len(children)): + for b in range(len(children)): + if born[a] < born[b]: + assert index[a] < index[b] + if ages[a] > ages[b]: + assert index[a] < index[b] + + +def cohort_share_born_before_cutoff(year: int, age: int) -> float: + """Share of people aged `age` on 6 October with birthdays spread evenly + over the days of the year who were born before 6 April 2017.""" + mid_year = date(year, 10, 6) + last = date(year - age, 10, 6) + first = date(year - age - 1, 10, 7) + days = [first + timedelta(days=d) for d in range((last - first).days + 1)] + return np.mean([day < CUTOFF for day in days]) + + +def cohort_share_born_before_calendar_year(year: int, age: int) -> float: + last = date(year - age, 10, 6) + first = date(year - age - 1, 10, 7) + days = [first + timedelta(days=d) for d in range((last - first).days + 1)] + return np.mean([day.year < year - age for day in days]) + + +def microdata_simulation(weights: np.ndarray, years): + """A simulation built from data: one child per household, aged 5 to 12 in + turn, alternating sex.""" + from policyengine_uk import Microsimulation + from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + + n = len(weights) + ids = np.arange(n) + ages = 5 + ids % 8 + + def year(y): + person = pd.DataFrame( + { + "person_id": ids * 10 + 3, + "person_benunit_id": ids, + "person_household_id": ids, + "age": ages, + "gender": np.where(ids % 16 < 8, "MALE", "FEMALE"), + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame({"household_id": ids, "household_weight": weights}), + fiscal_year=y, + ) + + dataset = UKMultiYearDataset(datasets=[year(y) for y in years]) + return Microsimulation(dataset=dataset), ages + + +def test_microdata_share_born_before_cutoff_matches_statute(): + """In data each single year of age and sex is spread evenly over the year + by weight, so the weighted share of the cohort that straddles 6 April 2017 + born before it is the statutory share, about half, where the birth-year + rule gave none. Every other cohort is wholly before or after.""" + weights = np.random.default_rng(3).lognormal(7, 0.5, 8 * 2 * 150) + years = (2022, 2024, 2025, 2026, 2028) + sim, ages = microdata_simulation(weights, years) + for year in years: + born = np.asarray(sim.calculate("date_of_birth", year)) + birth_year = np.asarray(sim.calculate("birth_year", year)) + uc = np.asarray(sim.calculate("uc_is_child_born_before_child_limit", year)) + ctc = np.asarray(sim.calculate("is_CTC_child_limit_exempt", year)) + assert np.array_equal(uc, born < ymd(CUTOFF)) + assert np.array_equal(ctc, born < ymd(CUTOFF)) + for age in range(5, 13): + cell = ages == age + # Within the largest weight in the cell, plus a day of births for + # where the month grid and the calendar differ. + tolerance = weights[cell].max() / weights[cell].sum() + 1 / 365 + share = np.average(born[cell] < ymd(CUTOFF), weights=weights[cell]) + expected = cohort_share_born_before_cutoff(year, age) + assert abs(share - expected) <= tolerance, (year, age, share) + earlier_year = np.average( + birth_year[cell] < year - age, weights=weights[cell] + ) + expected = cohort_share_born_before_calendar_year(year, age) + assert abs(earlier_year - expected) <= tolerance, (year, age) diff --git a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py index 9db8d79370..cff15105b2 100644 --- a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py @@ -5,16 +5,18 @@ class is_CTC_child_limit_exempt(Variable): value_type = bool entity = Person label = "Exemption from Child Tax Credit child limit" - documentation = "Exemption from Child Tax Credit limit on number of children based on birth year" + documentation = ( + "Whether the Child Tax Credit child limit does not apply to this child " + "or qualifying young person because they were born before 6 April 2017." + ) definition_period = YEAR + reference = "https://www.legislation.gov.uk/ukpga/2002/21/section/9" def formula(person, period, parameters): - limit_year = parameters( - period - ).gov.dwp.tax_credits.child_tax_credit.limit.start_year - # Children must be born before April 2017. - # We use < 2017 as the closer approximation than <= 2017. - born_before_limit = person("birth_year", period) < limit_year + limit = parameters(period).gov.dwp.tax_credits.child_tax_credit.limit + # Tax Credits Act 2002 s.9(3A): the limit applies to a child or + # qualifying young person born on or after 6 April 2017. + born_before_limit = person("date_of_birth", period) < limit.born_before # Reform proposal age_exemption = ( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py index 91af615384..79ad726c53 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py @@ -14,18 +14,21 @@ def formula(benunit, period, parameters): is_child = person( "is_child_or_qualifying_young_person_for_pension_credit", period ) + # Sch IIA para 10: the eldest child or qualifying young person, by date + # of birth, gets the first child amount if born before 6 April 2017. + date_of_birth = person("date_of_birth", period) child_index = ( person.get_rank( person.benunit, - -person("age", period), + date_of_birth, condition=is_child, ) + 1 ) - first_child_born_before_2017 = (child_index == 1) & ( - person("birth_year", period) < 2017 - ) gc = parameters(period).gov.dwp.pension_credit.guarantee_credit + first_child_born_before_cutoff = (child_index == 1) & ( + date_of_birth < gc.child.first.born_before + ) standard_disability_benefits = gc.child.disability.eligibility severe_disability_benefits = gc.child.disability.severe.eligibility is_disabled = add(person, period, standard_disability_benefits) > 0 @@ -33,7 +36,7 @@ def formula(benunit, period, parameters): is_standard_disabled = is_disabled & ~is_severely_disabled is_not_disabled = ~is_disabled child_addition = where( - first_child_born_before_2017, + first_child_born_before_cutoff, gc.child.first.addition, gc.child.addition, ) diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age.py b/policyengine_uk/variables/gov/dwp/state_pension_age.py index f93a15799e..e1c8931660 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_age.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_age.py @@ -3,7 +3,6 @@ add_months_to_yyyymmdd, exact_age_in_months, grid_month, - grid_months_to_yyyymmdd, yyyymmdd_to_grid_months, ) @@ -16,7 +15,8 @@ class state_pension_age(Variable): "The age at which this person attains State Pension age (pensionable " "age), set by their date of birth. Where the statute sets the day on " "which it is attained, this is the person's age on that day. The date " - "of birth comes from age and months_since_last_birthday." + "of birth is date_of_birth, which comes from age and " + "months_since_last_birthday." ) definition_period = YEAR unit = "year" @@ -35,8 +35,10 @@ def formula(person, period, parameters): ) birth = mid_year - age_in_months # The day of birth: the one starting at or after that instant, so the - # person's legal age on 6 October is age. - birth_date = grid_months_to_yyyymmdd(birth) + # person's legal age on 6 October is age. The result is measured from + # the instant, so months_since_state_pension_age, which subtracts it + # from the exact age, is 6 October less the day of attainment. + birth_date = person("date_of_birth", period) # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December # 1953 attain pensionable age at 65. Everyone else follows the diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py index f1f69a3f72..076f1ce195 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py @@ -9,6 +9,7 @@ class uc_child_index(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/24", "https://www.legislation.gov.uk/uksi/2013/376/regulation/24A", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/24B", "https://www.gov.uk/universal-credit/what-youll-get", ) @@ -16,10 +17,12 @@ def formula(person, period, parameters): is_uc_child = person( "is_child_or_qualifying_young_person_for_universal_credit", period ) & ~person("is_uc_claimant", period) + # Reg 24B orders children and qualifying young persons by date of + # birth, eldest first, where the claimant is their parent. child_ranking = ( person.get_rank( person.benunit, - -person("age", period), + person("date_of_birth", period), condition=is_uc_child, ) + 1 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py index 2220ee4586..541d8cb254 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py @@ -5,13 +5,21 @@ class uc_is_child_born_before_child_limit(Variable): value_type = bool entity = Person label = "Born before Universal Credit child limit" + documentation = ( + "Whether this child or qualifying young person was born before 6 April " + "2017, so is transitionally protected from the child limit, and their " + "responsible claimant keeps the higher child element for the first " + "child." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/24A/2025-04-06", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/43", + ) def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.limit - start_year = p.start_year - birth_year = person("birth_year", period) - born_before_limit = birth_year < start_year + born_before_limit = person("date_of_birth", period) < p.born_before return ( person("is_child_or_qualifying_young_person_for_universal_credit", period) & born_before_limit diff --git a/policyengine_uk/variables/household/demographic/birth_year.py b/policyengine_uk/variables/household/demographic/birth_year.py index f555e29c1f..571df8085c 100644 --- a/policyengine_uk/variables/household/demographic/birth_year.py +++ b/policyengine_uk/variables/household/demographic/birth_year.py @@ -1,12 +1,12 @@ from policyengine_uk.model_api import * -import pandas as pd class birth_year(Variable): value_type = int entity = Person label = "The birth year of the person" + documentation = "The calendar year of the person's date_of_birth." definition_period = YEAR def formula(person, period, parameters): - return period.start.year - person("age", period) + return person("date_of_birth", period) // 10000 diff --git a/policyengine_uk/variables/household/demographic/date_of_birth.py b/policyengine_uk/variables/household/demographic/date_of_birth.py new file mode 100644 index 0000000000..370ca56a96 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/date_of_birth.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import ( + exact_age_in_months, + grid_month, + grid_months_to_yyyymmdd, +) + + +class date_of_birth(Variable): + value_type = int + entity = Person + label = "date of birth" + documentation = ( + "The person's date of birth, written as a YYYYMMDD number, as date " + "parameters are. The person was born age years and " + "months_since_last_birthday months before the middle of the fiscal " + "year (6 October); the day of birth is the one starting at or after " + "that instant, so the person's legal age on 6 October is age (Family " + "Law Reform Act 1969 s.9(1)). Rules that turn on a date of birth, " + "such as State Pension age and the 6 April 2017 cutoffs in Universal " + "Credit, Child Tax Credit and Pension Credit, compare it with a date. " + "A person's age is held fixed across years, so their date of birth " + "moves with the period." + ) + definition_period = YEAR + unit = "date" + + def formula(person, period, parameters): + # Dates are in months on a grid whose months start on the 6th, so the + # middle of the fiscal year (6 October) is a whole month. + mid_year = grid_month(period.start.year, 10) + age_in_months = exact_age_in_months( + person("age", period), person("months_since_last_birthday", period) + ) + return grid_months_to_yyyymmdd(mid_year - age_in_months) From 42b358e2505210212839cf9a36349b2511ab59d9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 06:52:18 -0400 Subject: [PATCH 2/9] Quote reg 24B(1) in the UC child order comment Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/universal_credit/child_element/uc_child_index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py index 076f1ce195..7c49f56735 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py @@ -17,8 +17,8 @@ def formula(person, period, parameters): is_uc_child = person( "is_child_or_qualifying_young_person_for_universal_credit", period ) & ~person("is_uc_claimant", period) - # Reg 24B orders children and qualifying young persons by date of - # birth, eldest first, where the claimant is their parent. + # Reg 24B(1) orders children and qualifying young persons by date of + # birth, taking the earliest first. child_ranking = ( person.get_rank( person.benunit, From ef35551d3013847e5f38021863ad814832497315 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:25:44 -0400 Subject: [PATCH 3/9] Read every date-of-birth rule through one birth instant Review of #1904 found that a situation setting date_of_birth for some people gives the others 0: state_pension_age then read a birth date of 0 (a 35-year-old came out over State Pension age), unset siblings ranked first and counted as born before 6 April 2017. A date_of_birth input that disagreed with the age grid also gave a State Pension age measured from two different births. utils.dates.birth_instant now resolves the instant of birth once: the date_of_birth input where given (not 0, and not the day age and months_since_last_birthday already place), otherwise the instant from age and months_since_last_birthday. state_pension_age and months_since_state_pension_age share it, and the cutoffs, child order and birth_year read birth_day. Without an input nothing changes. Adds YAML regressions (partial input, exact State Pension age from a date, an unset sibling), a Hypothesis test that a date input matches the same birthday placed by age, UC flags in the household differential test, and changelog wording limited to whole ages. Co-Authored-By: Claude Opus 5.5 --- changelog.d/date-of-birth-cutoffs.added.md | 2 +- changelog.d/date-of-birth-cutoffs.fixed.md | 2 +- .../baseline/demographic/date_of_birth.yaml | 39 +++++++++++++ .../uc_is_child_born_before_child_limit.yaml | 26 +++++++++ policyengine_uk/tests/test_date_of_birth.py | 56 +++++++++++++++++-- policyengine_uk/utils/dates.py | 34 +++++++++++ .../gov/dwp/is_CTC_child_limit_exempt.py | 3 +- .../gov/dwp/months_since_state_pension_age.py | 9 +-- .../child_minimum_guarantee_addition.py | 3 +- .../variables/gov/dwp/state_pension_age.py | 24 ++++---- .../child_element/uc_child_index.py | 8 ++- .../uc_is_child_born_before_child_limit.py | 3 +- .../household/demographic/birth_year.py | 7 ++- .../household/demographic/date_of_birth.py | 20 ++++--- 14 files changed, 196 insertions(+), 40 deletions(-) diff --git a/changelog.d/date-of-birth-cutoffs.added.md b/changelog.d/date-of-birth-cutoffs.added.md index 9d2dd23b2c..044644a655 100644 --- a/changelog.d/date-of-birth-cutoffs.added.md +++ b/changelog.d/date-of-birth-cutoffs.added.md @@ -1 +1 @@ -Add `date_of_birth`, each person's day of birth as a YYYYMMDD number from `age` and `months_since_last_birthday`, and date parameters for the 6 April 2017 cutoffs: `gov.dwp.universal_credit.elements.child.limit.born_before`, `gov.dwp.tax_credits.child_tax_credit.limit.born_before` and `gov.dwp.pension_credit.guarantee_credit.child.first.born_before`. +Add `date_of_birth`, each person's day of birth as a YYYYMMDD number from `age` and `months_since_last_birthday`, which can also be set directly (with `age` as the person's age on 6 October) and then sets State Pension age and the cutoffs below; and add date parameters for the 6 April 2017 cutoffs: `gov.dwp.universal_credit.elements.child.limit.born_before`, `gov.dwp.tax_credits.child_tax_credit.limit.born_before` and `gov.dwp.pension_credit.guarantee_credit.child.first.born_before`. diff --git a/changelog.d/date-of-birth-cutoffs.fixed.md b/changelog.d/date-of-birth-cutoffs.fixed.md index 585a5abc0a..f0c34ca529 100644 --- a/changelog.d/date-of-birth-cutoffs.fixed.md +++ b/changelog.d/date-of-birth-cutoffs.fixed.md @@ -1 +1 @@ -Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) and the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations, which place each person in the middle of their year of age, are unchanged. +Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) and the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. diff --git a/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml b/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml index 7ae735b71c..da0a7b54d5 100644 --- a/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml +++ b/policyengine_uk/tests/policy/baseline/demographic/date_of_birth.yaml @@ -73,3 +73,42 @@ output: uc_is_child_born_before_child_limit: false is_CTC_child_limit_exempt: false + +- name: A date of birth set for one person leaves the others placed by age. + period: 2025 + absolute_error_margin: 0.001 + input: + people: + parent: + age: 35 + child: + age: 8 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # The parent's date_of_birth input is 0 (not given), so the rules place + # them on 6 April 1990, as with no input at all: State Pension age 68. + birth_year: [1990, 2017] + state_pension_age: [68, 68] + is_SP_age: [false, false] + uc_is_child_born_before_child_limit: [false, true] + +- name: A date of birth sets State Pension age exactly. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 66 + date_of_birth: 19600105 + output: + # Pensions Act 1995 Sch 4 para 1: born 6 October 1954 to 5 April 1960, + # State Pension age is 66, attained on 5 January 2026. From then to 6 + # October 2026 is 9 grid months and the 1 day of the grid month from 6 + # December 2025 to 5 January 2026 that falls after it: 10 - 30/31. + state_pension_age: 66 + is_SP_age: true + months_since_state_pension_age: 9.032 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml index f312ab2d40..65150601f2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/uc_is_child_born_before_child_limit.yaml @@ -148,3 +148,29 @@ output: uc_is_child_born_before_child_limit: [false, false] uc_individual_child_element: [0, 3_647.28] + +- name: Children without a date of birth input are placed by age. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_benunit_head: true + is_parent: true + child_1: + age: 10 + child_2: + age: 8 + child_3: + age: 8 + date_of_birth: 20170405 + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + output: + # child_2 has no input (0), so is placed on 6 April 2017: after child_3, + # and not protected. + uc_is_child_born_before_child_limit: [false, true, false, true] + uc_child_index: [-1, 1, 3, 2] + uc_individual_child_element: [0, 4_068, 0, 3_513.72] diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index 8fac33c276..416a44eddb 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -60,10 +60,10 @@ def situation(people: dict) -> dict: def test_household_situations_match_the_birth_year_rule(): """A whole age in a household situation is the middle of the year of age, - 6 April of the year the person turned that age. So for households the - date rule gives what the old birth-year rule (period - age < 2017) gave, - and birth_year is still period - age: only microdata, which spreads - birthdays over the year, moves.""" + 6 April of the year the person turned that age. So for households with + whole ages the date rule gives what the old birth-year rule (period - age + < 2017) gave, and birth_year is still period - age: only microdata, which + spreads birthdays over the year, and fractional ages move.""" for year in range(2015, 2036): ages = range(20) people = {f"p{age}": {"age": {year: age}} for age in ages} @@ -71,9 +71,12 @@ def test_household_situations_match_the_birth_year_rule(): born = sim.calculate("date_of_birth", year) birth_year = sim.calculate("birth_year", year) exempt = sim.calculate("is_CTC_child_limit_exempt", year) + uc = sim.calculate("uc_is_child_born_before_child_limit", year) old_rule = np.array([year - age < 2017 for age in ages]) assert np.array_equal(born < ymd(CUTOFF), old_rule), year assert np.array_equal(exempt, old_rule), year + under_16 = np.array(ages) < 16 + assert np.array_equal(uc[under_16], old_rule[under_16]), year assert np.array_equal(birth_year, [year - age for age in ages]), year @@ -237,3 +240,48 @@ def test_microdata_share_born_before_cutoff_matches_statute(): ) expected = cohort_share_born_before_calendar_year(year, age) assert abs(earlier_year - expected) <= tolerance, (year, age) + + +@settings(max_examples=25, deadline=None) +@given( + births=st.lists( + st.dates(min_value=date(1935, 1, 1), max_value=date(2020, 12, 31)), + min_size=1, + max_size=8, + ), + year=st.integers(min_value=2021, max_value=2040), +) +def test_a_date_of_birth_input_matches_the_same_birthday_by_age(births, year): + """Setting date_of_birth (with the legal age on 6 October) gives the same + State Pension age, status and cutoffs as placing the same day with + months_since_last_birthday. A person left without the input in the same + situation gets what they would with no input at all.""" + mid_year = date(year, 10, 6) + by_date = {"unset": {"age": {year: 40}}} + by_months = {"unset": {"age": {year: 40}, "months_since_last_birthday": {year: 6}}} + for i, birth in enumerate(births): + age = legal_age(birth, mid_year) + months = grid_months(mid_year) - grid_months(birth) - 12 * age + by_date[f"p{i}"] = {"age": {year: age}, "date_of_birth": {year: ymd(birth)}} + by_months[f"p{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + a = Simulation(situation=situation(by_date)) + b = Simulation(situation=situation(by_months)) + for variable in [ + "birth_year", + "is_SP_age", + "uc_is_child_born_before_child_limit", + "is_CTC_child_limit_exempt", + ]: + assert np.array_equal( + a.calculate(variable, year), b.calculate(variable, year) + ), variable + assert np.allclose( + a.calculate("state_pension_age", year), + b.calculate("state_pension_age", year), + atol=1e-4, + ) + assert list(a.calculate("date_of_birth", year)[1:]) == [ymd(d) for d in births] + assert a.calculate("date_of_birth", year)[0] == 0 diff --git a/policyengine_uk/utils/dates.py b/policyengine_uk/utils/dates.py index 658ae263bf..242f69caa4 100644 --- a/policyengine_uk/utils/dates.py +++ b/policyengine_uk/utils/dates.py @@ -115,3 +115,37 @@ def exact_age_in_months(age, months_since_last_birthday) -> np.ndarray: _LATEST_MONTHS_SINCE_BIRTHDAY, ) return 12 * whole_years + months + + +def birth_instant_from_age(year: int, age, months_since_last_birthday) -> np.ndarray: + """The instant of birth in grid months: age years and + months_since_last_birthday months before 6 October of ``year``.""" + return grid_month(year, 10) - exact_age_in_months(age, months_since_last_birthday) + + +def birth_instant(person, period) -> np.ndarray: + """A person's instant of birth in grid months, in float64, for every rule + that turns on the date of birth. + + Where date_of_birth is given as an input and is not the day that age and + months_since_last_birthday place, it is the start of that day. Otherwise + it is the instant from age and months_since_last_birthday. A situation + that sets date_of_birth for some people gives everyone else 0, which + means not given. + """ + from_age = birth_instant_from_age( + period.start.year, + person("age", period), + person("months_since_last_birthday", period), + ) + given = np.asarray(person("date_of_birth", period), dtype=np.int64) + use_given = (given > 0) & (given != grid_months_to_yyyymmdd(from_age)) + # Any valid date stands in where the input is not used. + safe_given = np.where(use_given, given, 19700106) + return np.where(use_given, yyyymmdd_to_grid_months(safe_given), from_age) + + +def birth_day(person, period) -> np.ndarray: + """A person's day of birth as a YYYYMMDD number: date_of_birth where + given, and otherwise the day age and months_since_last_birthday place.""" + return grid_months_to_yyyymmdd(birth_instant(person, period)) diff --git a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py index cff15105b2..5630d30d41 100644 --- a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class is_CTC_child_limit_exempt(Variable): @@ -16,7 +17,7 @@ def formula(person, period, parameters): limit = parameters(period).gov.dwp.tax_credits.child_tax_credit.limit # Tax Credits Act 2002 s.9(3A): the limit applies to a child or # qualifying young person born on or after 6 April 2017. - born_before_limit = person("date_of_birth", period) < limit.born_before + born_before_limit = birth_day(person, period) < limit.born_before # Reform proposal age_exemption = ( diff --git a/policyengine_uk/variables/gov/dwp/months_since_state_pension_age.py b/policyengine_uk/variables/gov/dwp/months_since_state_pension_age.py index 53726ce48c..3137400db6 100644 --- a/policyengine_uk/variables/gov/dwp/months_since_state_pension_age.py +++ b/policyengine_uk/variables/gov/dwp/months_since_state_pension_age.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.dates import exact_age_in_months +from policyengine_uk.utils.dates import birth_instant, grid_month class months_since_state_pension_age(Variable): @@ -9,14 +9,15 @@ class months_since_state_pension_age(Variable): documentation = ( "Months between the day this person attains State Pension age and the " "middle of the fiscal year (6 October); negative if they attain it " - "later. Exact age is age plus months_since_last_birthday." + "later. Exact age is measured from the same instant of birth as " + "state_pension_age." ) definition_period = YEAR unit = "month" def formula(person, period, parameters): - age_in_months = exact_age_in_months( - person("age", period), person("months_since_last_birthday", period) + age_in_months = grid_month(period.start.year, 10) - birth_instant( + person, period ) spa = person("state_pension_age", period).astype(np.float64) months_since = age_in_months - 12 * spa diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py index 79ad726c53..922cc86e06 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/child_minimum_guarantee_addition.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class child_minimum_guarantee_addition(Variable): @@ -16,7 +17,7 @@ def formula(benunit, period, parameters): ) # Sch IIA para 10: the eldest child or qualifying young person, by date # of birth, gets the first child amount if born before 6 April 2017. - date_of_birth = person("date_of_birth", period) + date_of_birth = birth_day(person, period) child_index = ( person.get_rank( person.benunit, diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age.py b/policyengine_uk/variables/gov/dwp/state_pension_age.py index e1c8931660..85627a4d04 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_age.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_age.py @@ -1,8 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.dates import ( add_months_to_yyyymmdd, - exact_age_in_months, - grid_month, + birth_instant, + grid_months_to_yyyymmdd, yyyymmdd_to_grid_months, ) @@ -15,7 +15,7 @@ class state_pension_age(Variable): "The age at which this person attains State Pension age (pensionable " "age), set by their date of birth. Where the statute sets the day on " "which it is attained, this is the person's age on that day. The date " - "of birth is date_of_birth, which comes from age and " + "of birth is date_of_birth where given, and otherwise comes from age and " "months_since_last_birthday." ) definition_period = YEAR @@ -27,18 +27,14 @@ class state_pension_age(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.state_pension.age - # Dates are in months on a grid whose months start on the 6th, so the - # middle of the fiscal year (6 October) is a whole month. - mid_year = grid_month(period.start.year, 10) - age_in_months = exact_age_in_months( - person("age", period), person("months_since_last_birthday", period) - ) - birth = mid_year - age_in_months + # Dates are in months on a grid whose months start on the 6th. The + # result is measured from the instant of birth, which + # months_since_state_pension_age shares, so that is 6 October less the + # day of attainment. + birth = birth_instant(person, period) # The day of birth: the one starting at or after that instant, so the - # person's legal age on 6 October is age. The result is measured from - # the instant, so months_since_state_pension_age, which subtracts it - # from the exact age, is 6 October less the day of attainment. - birth_date = person("date_of_birth", period) + # person's legal age on 6 October is age. + birth_date = grid_months_to_yyyymmdd(birth) # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December # 1953 attain pensionable age at 65. Everyone else follows the diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py index 7c49f56735..56ffb7fbe4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_child_index.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class uc_child_index(Variable): @@ -18,11 +19,14 @@ def formula(person, period, parameters): "is_child_or_qualifying_young_person_for_universal_credit", period ) & ~person("is_uc_claimant", period) # Reg 24B(1) orders children and qualifying young persons by date of - # birth, taking the earliest first. + # birth, taking the earliest first. Regs 24A and 24B were revoked from + # 6 April 2026; the eldest child is then still the one for whom the + # reg 43 saving is checked, as it holds if any child was born before + # the cutoff. child_ranking = ( person.get_rank( person.benunit, - person("date_of_birth", period), + birth_day(person, period), condition=is_uc_child, ) + 1 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py index 541d8cb254..4eca4b0040 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_is_child_born_before_child_limit.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class uc_is_child_born_before_child_limit(Variable): @@ -19,7 +20,7 @@ class uc_is_child_born_before_child_limit(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.limit - born_before_limit = person("date_of_birth", period) < p.born_before + born_before_limit = birth_day(person, period) < p.born_before return ( person("is_child_or_qualifying_young_person_for_universal_credit", period) & born_before_limit diff --git a/policyengine_uk/variables/household/demographic/birth_year.py b/policyengine_uk/variables/household/demographic/birth_year.py index 571df8085c..85cd88b27a 100644 --- a/policyengine_uk/variables/household/demographic/birth_year.py +++ b/policyengine_uk/variables/household/demographic/birth_year.py @@ -1,12 +1,15 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class birth_year(Variable): value_type = int entity = Person label = "The birth year of the person" - documentation = "The calendar year of the person's date_of_birth." + documentation = ( + "The calendar year of the person's date of birth (date_of_birth where given)." + ) definition_period = YEAR def formula(person, period, parameters): - return person("date_of_birth", period) // 10000 + return birth_day(person, period) // 10000 diff --git a/policyengine_uk/variables/household/demographic/date_of_birth.py b/policyengine_uk/variables/household/demographic/date_of_birth.py index 370ca56a96..3339273319 100644 --- a/policyengine_uk/variables/household/demographic/date_of_birth.py +++ b/policyengine_uk/variables/household/demographic/date_of_birth.py @@ -1,7 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.dates import ( - exact_age_in_months, - grid_month, + birth_instant_from_age, grid_months_to_yyyymmdd, ) @@ -20,16 +19,19 @@ class date_of_birth(Variable): "such as State Pension age and the 6 April 2017 cutoffs in Universal " "Credit, Child Tax Credit and Pension Credit, compare it with a date. " "A person's age is held fixed across years, so their date of birth " - "moves with the period." + "moves with the period. It can be set directly, with age set to the " + "person's age on 6 October; a situation that sets it for some people " + "gives the others 0, which the rules read as not given." ) definition_period = YEAR unit = "date" def formula(person, period, parameters): - # Dates are in months on a grid whose months start on the 6th, so the - # middle of the fiscal year (6 October) is a whole month. - mid_year = grid_month(period.start.year, 10) - age_in_months = exact_age_in_months( - person("age", period), person("months_since_last_birthday", period) + # On a grid whose months start on the 6th: age years and + # months_since_last_birthday months before 6 October. + birth = birth_instant_from_age( + period.start.year, + person("age", period), + person("months_since_last_birthday", period), ) - return grid_months_to_yyyymmdd(mid_year - age_in_months) + return grid_months_to_yyyymmdd(birth) From f9a747c77eaac2376886de7140e772caba090c33 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:42:10 -0400 Subject: [PATCH 4/9] Reject date_of_birth inputs that are not dates or contradict age Round-2 review of #1904: an input was accepted as any positive integer and normalised silently (20170229 became 1 March, 2017 read as a date in year 1, making a child over State Pension age), and nothing checked it against age (a child given only a date kept the default age). birth_instant now raises a ValueError naming the input for a non-date, or for a date whose legal age on 6 October is not floor(age). Inputs that agree with age are unaffected, and data never carries the input. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_date_of_birth.py | 25 +++++++++++++ policyengine_uk/utils/dates.py | 36 ++++++++++++++++++- .../household/demographic/date_of_birth.py | 8 +++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index 416a44eddb..636933234e 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd +import pytest from hypothesis import given, settings from hypothesis import strategies as st @@ -285,3 +286,27 @@ def test_a_date_of_birth_input_matches_the_same_birthday_by_age(births, year): ) assert list(a.calculate("date_of_birth", year)[1:]) == [ymd(d) for d in births] assert a.calculate("date_of_birth", year)[0] == 0 + + +@pytest.mark.parametrize( + "date_of_birth, age", + [ + (20170229, 8), # 29 February 2017 does not exist + (20170340, 8), # nor does 40 March + (2017, 8), # a birth year, not a date + ], +) +def test_an_invalid_date_of_birth_is_rejected(date_of_birth, age): + people = {"child": {"age": {2025: age}, "date_of_birth": {2025: date_of_birth}}} + sim = Simulation(situation=situation(people)) + with pytest.raises(ValueError, match="calendar date"): + sim.calculate("uc_is_child_born_before_child_limit", 2025) + + +def test_a_date_of_birth_that_contradicts_age_is_rejected(): + """A child given only a date of birth keeps the default age; the rules + would read them as a 40-year-old born in 2017.""" + people = {"child": {"date_of_birth": {2025: 20170405}}} + sim = Simulation(situation=situation(people)) + with pytest.raises(ValueError, match="aged 8 on 6 October 2025"): + sim.calculate("is_SP_age", 2025) diff --git a/policyengine_uk/utils/dates.py b/policyengine_uk/utils/dates.py index 242f69caa4..89150d9ffd 100644 --- a/policyengine_uk/utils/dates.py +++ b/policyengine_uk/utils/dates.py @@ -142,7 +142,41 @@ def birth_instant(person, period) -> np.ndarray: use_given = (given > 0) & (given != grid_months_to_yyyymmdd(from_age)) # Any valid date stands in where the input is not used. safe_given = np.where(use_given, given, 19700106) - return np.where(use_given, yyyymmdd_to_grid_months(safe_given), from_age) + given_instant = yyyymmdd_to_grid_months(safe_given) + if use_given.any(): + _check_given_dates_of_birth( + given[use_given], + given_instant[use_given], + np.asarray(person("age", period), dtype=np.float64)[use_given], + period.start.year, + ) + return np.where(use_given, given_instant, from_age) + + +def _check_given_dates_of_birth(dates, instants, ages, year: int) -> None: + """Reject a date_of_birth input that is not a calendar date, or whose + legal age on 6 October is not the person's age: silently normalising + either would misplace the person in every date-of-birth rule.""" + invalid = grid_months_to_yyyymmdd(instants) != dates + if invalid.any(): + raise ValueError( + "date_of_birth must be a calendar date written as YYYYMMDD, such " + f"as 20170405; got {sorted(set(dates[invalid].tolist()))[:5]}." + ) + legal_age = np.floor((grid_month(year, 10) - instants) / 12) + mismatch = legal_age != np.floor(ages) + if mismatch.any(): + examples = [ + f"{d} is aged {int(a)} on 6 October {year}, not {int(np.floor(g))}" + for d, a, g in zip( + dates[mismatch][:5], legal_age[mismatch][:5], ages[mismatch][:5] + ) + ] + raise ValueError( + "Set age to the age on 6 October that date_of_birth gives: " + + "; ".join(examples) + + "." + ) def birth_day(person, period) -> np.ndarray: diff --git a/policyengine_uk/variables/household/demographic/date_of_birth.py b/policyengine_uk/variables/household/demographic/date_of_birth.py index 3339273319..025f3a934b 100644 --- a/policyengine_uk/variables/household/demographic/date_of_birth.py +++ b/policyengine_uk/variables/household/demographic/date_of_birth.py @@ -19,9 +19,11 @@ class date_of_birth(Variable): "such as State Pension age and the 6 April 2017 cutoffs in Universal " "Credit, Child Tax Credit and Pension Credit, compare it with a date. " "A person's age is held fixed across years, so their date of birth " - "moves with the period. It can be set directly, with age set to the " - "person's age on 6 October; a situation that sets it for some people " - "gives the others 0, which the rules read as not given." + "moves with the period. It can be set directly for a year, with age " + "set to the person's age on 6 October of it (anything else raises an " + "error); like age, an input applies only to the year it is given for. " + "A situation that sets it for some people gives the others 0, which " + "the rules read as not given." ) definition_period = YEAR unit = "date" From 86523cf3f74741f3abf4576dbeb75830d99c08d9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:34:04 -0400 Subject: [PATCH 5/9] Apply the mixed-age couple saving's birth cutoff by date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1940 (merged after this branch last synced) added the SI 2019/37 art. 4 saving with birth_year <= 1954, a year stand-in for reaching the State Pension Credit qualifying age by 14 May 2019, i.e. born on or before 5 February 1954. With birth_year now the calendar year of the exact date, people born October-December 1954 entered that rule; in the Enhanced FRS one benefit unit (weight 10,598) gained £83m of Pension Credit in 2027-28. Replace the year with saving_latest_birth_date (19540205) and compare birth_day with it; add 5 vs 6 February 1954 YAML boundaries and tighten the property test's P8 to the date. Co-Authored-By: Claude Opus 5.5 --- changelog.d/date-of-birth-cutoffs.fixed.md | 2 +- changelog.d/date-of-birth-cutoffs.removed.md | 2 +- .../saving_latest_birth_date.yaml | 13 ++++++ .../saving_latest_birth_year.yaml | 13 ------ .../dwp/pension_credit/mixed_age_couples.yaml | 46 +++++++++++++++++++ .../tests/test_mixed_age_couple_properties.py | 8 ++-- ..._mixed_age_couple_pension_credit_saving.py | 5 +- 7 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_date.yaml delete mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_year.yaml diff --git a/changelog.d/date-of-birth-cutoffs.fixed.md b/changelog.d/date-of-birth-cutoffs.fixed.md index f0c34ca529..8c0f672890 100644 --- a/changelog.d/date-of-birth-cutoffs.fixed.md +++ b/changelog.d/date-of-birth-cutoffs.fixed.md @@ -1 +1 @@ -Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) and the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. +Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10), and the Pension Credit mixed-age couple saving, which needs the older member born on or before 5 February 1954 to have reached the qualifying age by 14 May 2019 (SI 2019/37 art. 4). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. diff --git a/changelog.d/date-of-birth-cutoffs.removed.md b/changelog.d/date-of-birth-cutoffs.removed.md index 4e941f0474..3ff6bc1fb0 100644 --- a/changelog.d/date-of-birth-cutoffs.removed.md +++ b/changelog.d/date-of-birth-cutoffs.removed.md @@ -1 +1 @@ -Remove `gov.dwp.universal_credit.elements.child.limit.start_year` and `gov.dwp.tax_credits.child_tax_credit.limit.start_year`, which held the year 2017. A reform to the cutoff now sets the matching `born_before` date parameter, written as a YYYYMMDD number (for example 20170406). +Remove `gov.dwp.universal_credit.elements.child.limit.start_year` and `gov.dwp.tax_credits.child_tax_credit.limit.start_year`, which held the year 2017, and `gov.dwp.pension_credit.mixed_age_couples.saving_latest_birth_year` (1954). A reform to a cutoff now sets the matching date parameter, written as a YYYYMMDD number: `born_before` (20170406) or `saving_latest_birth_date` (19540205). diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_date.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_date.yaml new file mode 100644 index 0000000000..c79b30feeb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_date.yaml @@ -0,0 +1,13 @@ +description: Latest date of birth, written as a YYYYMMDD number, of the older member of a mixed-age couple for the couple to keep Pension Credit under the SI 2019/37 article 4 saving. The older member must have reached the qualifying age for State Pension Credit by 14 May 2019; a woman born on or before 5 February 1954 reached it on 6 May 2019, and one born on 6 February 1954 on 6 July 2019 (Pensions Act 1995 Sch 4 para 1, table 2). +values: + 2019-05-15: 19540205 +metadata: + unit: date + label: Latest date of birth for the mixed-age couple Pension Credit saving + reference: + - title: SI 2019/37 art. 4(1) + href: https://www.legislation.gov.uk/uksi/2019/37/article/4 + - title: Pensions Act 1995 Sch 4 para 1 (pensionable age by date of birth) + href: https://www.legislation.gov.uk/ukpga/1995/26/schedule/4 + - title: State Pension Credit Act 2002 s.1(6) (qualifying age) + href: https://www.legislation.gov.uk/ukpga/2002/16/section/1 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_year.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_year.yaml deleted file mode 100644 index 9207413045..0000000000 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/mixed_age_couples/saving_latest_birth_year.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Latest birth year of the older member of a mixed-age couple for the couple to keep Pension Credit under the SI 2019/37 article 4 saving. The older member must have reached the qualifying age for State Pension Credit by 14 May 2019, which means being born on or before 5 February 1954. With whole-year ages the model admits the whole of 1954. -values: - 2019-05-15: 1954 -metadata: - unit: year - label: Latest birth year for the mixed-age couple Pension Credit saving - reference: - - title: SI 2019/37 art. 4(1) - href: https://www.legislation.gov.uk/uksi/2019/37/article/4 - - title: Pensions Act 1995 Sch 4 para 1 (pensionable age by date of birth) - href: https://www.legislation.gov.uk/ukpga/1995/26/schedule/4 - - title: State Pension Credit Act 2002 s.1(6) (qualifying age) - href: https://www.legislation.gov.uk/ukpga/2002/16/section/1 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml index 14846b2ead..83e9f1ca82 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml @@ -635,3 +635,49 @@ output: is_mixed_age_couple: true meets_pension_credit_age_conditions: true + +# SI 2019/37 art. 4(1): the older member must have reached the qualifying age +# by 14 May 2019, so been born on or before 5 February 1954 (State Pension age +# 6 May 2019; a birth on 6 February 1954 reaches it on 6 July 2019). Ages are +# the legal ages on 6 October 2026. +- name: Mixed-age saving, older member born 5 February 1954, is set + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 72 + date_of_birth: 19540205 + state_pension: 9_000 + pension_credit_reported: 1_000 + younger: + age: 60 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + output: + has_mixed_age_couple_pension_credit_saving: true + +- name: Mixed-age saving, older member born 6 February 1954, is not set + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 72 + date_of_birth: 19540206 + state_pension: 9_000 + pension_credit_reported: 1_000 + younger: + age: 60 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + output: + has_mixed_age_couple_pension_credit_saving: false diff --git a/policyengine_uk/tests/test_mixed_age_couple_properties.py b/policyengine_uk/tests/test_mixed_age_couple_properties.py index a6b08165e9..cc469c4134 100644 --- a/policyengine_uk/tests/test_mixed_age_couple_properties.py +++ b/policyengine_uk/tests/test_mixed_age_couple_properties.py @@ -25,8 +25,8 @@ Universal Credit meets the Pension Credit age conditions, whatever the saving input; one reported on UC meets them only with the saving. P8 The default saving is never set where the older member was born after - 1954 (so had not reached the qualifying age by 14 May 2019), or where - the couple reports Universal Credit. + 5 February 1954 (so had not reached the qualifying age by 14 May 2019), + or where the couple reports Universal Credit. """ import json @@ -270,5 +270,7 @@ def test_mixed_age_couple_invariants(case): assert default["age_conditions"] == (not on_uc), context older = case["people"]["older"] if default["saving"]: # P8 - assert case["year"] - older["age"] <= 1954, context + # A whole age places the birth on 6 April of year - age, which is on + # or before 5 February 1954 only if year - age is 1953 or earlier. + assert case["year"] - older["age"] <= 1953, context assert older["universal_credit_reported"] == 0, context diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py index 57b3d9b4e7..9f36d6450a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class has_mixed_age_couple_pension_credit_saving(Variable): @@ -66,11 +67,11 @@ def reported(variables): receives_uc = reported(["universal_credit_reported"]) # Art. 4(1) needs the couple to have been a mixed-age couple on # 14 May 2019, so its older member had reached the qualifying age by - # then. + # then: born on or before 5 February 1954. older_member_qualified = benunit.any( claimant_or_partner & person("is_SP_age", period) - & (person("birth_year", period) <= p.saving_latest_birth_year) + & (birth_day(person, period) <= p.saving_latest_birth_date) ) return ( benunit("is_mixed_age_couple", period) From c6db319737160b2b217577c86bec083bad244814 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 22:13:36 -0400 Subject: [PATCH 6/9] Reject negative date_of_birth inputs; scope the unchanged claim GPT-6.1 Sol review of 86523cf3f (APPROVE, should-fixes): - birth_instant treated any negative date_of_birth as not given; only 0 means that now, and a negative value raises. - The changelog said whole-age situations are unchanged; that holds for the child rules, not the mixed-age saving (an older member aged period less 1954 is placed on 6 April 1954, after the 5 February cutoff). - The UC born_before reference now says reg 24A(3) took its date wording from 1 February 2019; before then it pointed to SI 2014/1230 reg 40, whose continuous-entitlement condition is not modelled. Co-Authored-By: Claude Opus 5.5 --- changelog.d/date-of-birth-cutoffs.fixed.md | 2 +- .../universal_credit/elements/child/limit/born_before.yaml | 2 +- policyengine_uk/tests/test_date_of_birth.py | 1 + policyengine_uk/utils/dates.py | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/changelog.d/date-of-birth-cutoffs.fixed.md b/changelog.d/date-of-birth-cutoffs.fixed.md index 8c0f672890..57939b200e 100644 --- a/changelog.d/date-of-birth-cutoffs.fixed.md +++ b/changelog.d/date-of-birth-cutoffs.fixed.md @@ -1 +1 @@ -Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10), and the Pension Credit mixed-age couple saving, which needs the older member born on or before 5 February 1954 to have reached the qualifying age by 14 May 2019 (SI 2019/37 art. 4). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. Household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. +Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10), and the Pension Credit mixed-age couple saving, which needs the older member born on or before 5 February 1954 to have reached the qualifying age by 14 May 2019 (SI 2019/37 art. 4). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. For the child rules, household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. The mixed-age saving does change for whole ages: a situation's older member aged period less 1954 is placed on 6 April 1954, after the cutoff, where the birth year admitted them. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml index 0b56faa862..3d9560bcdc 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/born_before.yaml @@ -6,7 +6,7 @@ metadata: period: year label: Universal Credit child limit and higher first child element cutoff date of birth reference: - - title: The Universal Credit Regulations 2013 reg. 24A(3) (as in force from 6 April 2017 to 5 April 2026) + - title: The Universal Credit Regulations 2013 reg. 24A(3) (born before 6 April 2017, as worded from 1 February 2019 until revoked from 6 April 2026; from 6 April 2017 to 31 January 2019 it pointed to UC (Transitional Provisions) Regs 2014 reg 40, which also required continuous entitlement since the interim period, a condition not modelled) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/24A/2025-04-06 - title: The Universal Credit (Transitional Provisions) Regulations 2014 reg. 43 (saving of the higher first child element) href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/43 diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index 636933234e..80e65a74ba 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -294,6 +294,7 @@ def test_a_date_of_birth_input_matches_the_same_birthday_by_age(births, year): (20170229, 8), # 29 February 2017 does not exist (20170340, 8), # nor does 40 March (2017, 8), # a birth year, not a date + (-20170405, 8), # 0 means not given; a negative number is no date ], ) def test_an_invalid_date_of_birth_is_rejected(date_of_birth, age): diff --git a/policyengine_uk/utils/dates.py b/policyengine_uk/utils/dates.py index 21664e1f85..6c503f525c 100644 --- a/policyengine_uk/utils/dates.py +++ b/policyengine_uk/utils/dates.py @@ -145,6 +145,11 @@ def birth_instant(person, period) -> np.ndarray: person("months_since_last_birthday", period), ) given = np.asarray(person("date_of_birth", period), dtype=np.int64) + if (given < 0).any(): + raise ValueError( + "date_of_birth must be a calendar date written as YYYYMMDD, such " + f"as 20170405, or 0 for not given; got {sorted(set(given[given < 0].tolist()))[:5]}." + ) use_given = (given > 0) & (given != grid_months_to_yyyymmdd(from_age)) # Any valid date stands in where the input is not used. safe_given = np.where(use_given, given, 19700106) From 20e67683ddb031aad91e94139d8f068119348a25 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:28:57 -0400 Subject: [PATCH 7/9] Drop the unused age-only date_of_birth helper; test the input path In-session review of 297a210f8 (engine reviewer APPROVE, nits): - utils.state_pension_age.date_of_birth had no callers after the merge and built the birth from age alone, so a future caller would ignore a date_of_birth input; delete it (birth_instant is the one path). - test_a_date_of_birth_input_matches_the_same_birthday_by_age now also checks state_pension_credit_qualifying_age, its attainment, months_since_state_pension_age and the Savings Credit age test, for both sexes and years from 2015 (when SPC qualifying age still differs for men born before 6 December 1953). - Document a direct date_of_birth input in the State Pension docs; fix changelog wording and list saving_latest_birth_date among the added parameters. Co-Authored-By: Claude Opus 5.5 --- changelog.d/date-of-birth-cutoffs.added.md | 2 +- changelog.d/date-of-birth-cutoffs.fixed.md | 2 +- docs/book/programs/gov/dwp/state-pension.md | 7 ++- policyengine_uk/tests/test_date_of_birth.py | 59 +++++++++++++++------ policyengine_uk/utils/state_pension_age.py | 14 ----- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/changelog.d/date-of-birth-cutoffs.added.md b/changelog.d/date-of-birth-cutoffs.added.md index 044644a655..a60cef2e70 100644 --- a/changelog.d/date-of-birth-cutoffs.added.md +++ b/changelog.d/date-of-birth-cutoffs.added.md @@ -1 +1 @@ -Add `date_of_birth`, each person's day of birth as a YYYYMMDD number from `age` and `months_since_last_birthday`, which can also be set directly (with `age` as the person's age on 6 October) and then sets State Pension age and the cutoffs below; and add date parameters for the 6 April 2017 cutoffs: `gov.dwp.universal_credit.elements.child.limit.born_before`, `gov.dwp.tax_credits.child_tax_credit.limit.born_before` and `gov.dwp.pension_credit.guarantee_credit.child.first.born_before`. +Add `date_of_birth`, each person's day of birth as a YYYYMMDD number from `age` and `months_since_last_birthday`, which can also be set directly (with `age` as the person's age on 6 October) and then sets State Pension age and the cutoffs below; and add date parameters for the 6 April 2017 cutoffs: `gov.dwp.universal_credit.elements.child.limit.born_before`, `gov.dwp.tax_credits.child_tax_credit.limit.born_before` and `gov.dwp.pension_credit.guarantee_credit.child.first.born_before`, and for the Pension Credit mixed-age couple saving, `gov.dwp.pension_credit.mixed_age_couples.saving_latest_birth_date` (5 February 1954). diff --git a/changelog.d/date-of-birth-cutoffs.fixed.md b/changelog.d/date-of-birth-cutoffs.fixed.md index 57939b200e..83ce91956a 100644 --- a/changelog.d/date-of-birth-cutoffs.fixed.md +++ b/changelog.d/date-of-birth-cutoffs.fixed.md @@ -1 +1 @@ -Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)) the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10), and the Pension Credit mixed-age couple saving, which needs the older member born on or before 5 February 1954 to have reached the qualifying age by 14 May 2019 (SI 2019/37 art. 4). They compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. For the child rules, household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. The mixed-age saving does change for whole ages: a situation's older member aged period less 1954 is placed on 6 April 1954, after the cutoff, where the birth year admitted them. +Apply the "born before 6 April 2017" rules by date of birth: the Universal Credit child limit and higher first child element (UC Regs 2013 reg 24A(3); UC (Transitional Provisions) Regs 2014 reg 43), the Child Tax Credit child limit (Tax Credits Act 2002 s.9(3A)), the Pension Credit first child addition (SPC Regs 2002 Sch IIA para 10), and the Pension Credit mixed-age couple saving, which needs the older member born on or before 5 February 1954 to have reached the qualifying age by 14 May 2019 (SI 2019/37 art. 4). The child rules compared a birth year of the period less the whole age with 2017, so in survey data the whole cohort born from October 2016 to October 2017 counted as born after the cutoff, when about half was born before it. Universal Credit and Pension Credit also now order children by date of birth (UC Regs reg 24B), so the first or eldest child is the one born first. For the child rules, household situations with whole ages, which place each person in the middle of their year of age, are unchanged; a fractional age is now read as the exact age on 6 October, as it already is for State Pension age. The mixed-age saving does change for whole ages: a situation's older member aged period less 1954 is placed on 6 April 1954, after the cutoff, where the birth year admitted them. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index cc40a7e816..32aec4e40b 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -96,7 +96,12 @@ the exact age on 6 October, overriding `months_since_last_birthday` even when another person supplies birthday months. The default implies a birth date of 6 April: an entered age of 66 is above State Pension age at the 6 October check in 2026-27 and below it in 2027-28. Set a fractional age -or `months_since_last_birthday` to specify a different inferred birth date. +or `months_since_last_birthday` to specify a different inferred birth date, +or set `date_of_birth` directly (a YYYYMMDD number, with `age` as the age on +6 October): `state_pension_age`, `months_since_state_pension_age`, +`is_SP_age`, the State Pension Credit qualifying age and its attainment, and +the Savings Credit age test then all follow that day. A value that is not a +calendar date, or that gives a different age, raises an error. Survey microdata records whole years only, so in simulations built from data, including a region or constituency filtered from it, each single year of age and sex is spread evenly over the year by diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index 80e65a74ba..0502362f31 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -243,47 +243,72 @@ def test_microdata_share_born_before_cutoff_matches_statute(): assert abs(earlier_year - expected) <= tolerance, (year, age) +@st.composite +def births_and_year(draw): + """A year from 2015 (when the State Pension Credit qualifying age still + differs from State Pension age for men born before 6 December 1953) and + people born before its 6 October, of either sex.""" + year = draw(st.integers(min_value=2015, max_value=2040)) + births = draw( + st.lists( + st.tuples( + st.dates(min_value=date(1935, 1, 1), max_value=date(year - 1, 12, 31)), + st.booleans(), + ), + min_size=1, + max_size=8, + ) + ) + return births, year + + @settings(max_examples=25, deadline=None) -@given( - births=st.lists( - st.dates(min_value=date(1935, 1, 1), max_value=date(2020, 12, 31)), - min_size=1, - max_size=8, - ), - year=st.integers(min_value=2021, max_value=2040), -) -def test_a_date_of_birth_input_matches_the_same_birthday_by_age(births, year): +@given(case=births_and_year()) +def test_a_date_of_birth_input_matches_the_same_birthday_by_age(case): """Setting date_of_birth (with the legal age on 6 October) gives the same - State Pension age, status and cutoffs as placing the same day with + State Pension age, State Pension Credit qualifying age, statuses, Savings + Credit age test and cutoffs as placing the same day with months_since_last_birthday. A person left without the input in the same situation gets what they would with no input at all.""" + people, year = case + births = [birth for birth, _ in people] mid_year = date(year, 10, 6) by_date = {"unset": {"age": {year: 40}}} by_months = {"unset": {"age": {year: 40}, "months_since_last_birthday": {year: 6}}} - for i, birth in enumerate(births): + for i, (birth, male) in enumerate(people): age = legal_age(birth, mid_year) months = grid_months(mid_year) - grid_months(birth) - 12 * age - by_date[f"p{i}"] = {"age": {year: age}, "date_of_birth": {year: ymd(birth)}} + by_date[f"p{i}"] = { + "age": {year: age}, + "date_of_birth": {year: ymd(birth)}, + "is_male": {year: male}, + } by_months[f"p{i}"] = { "age": {year: age}, "months_since_last_birthday": {year: months}, + "is_male": {year: male}, } a = Simulation(situation=situation(by_date)) b = Simulation(situation=situation(by_months)) for variable in [ "birth_year", "is_SP_age", + "has_attained_state_pension_credit_qualifying_age", + "meets_savings_credit_age_requirement", "uc_is_child_born_before_child_limit", "is_CTC_child_limit_exempt", ]: assert np.array_equal( a.calculate(variable, year), b.calculate(variable, year) ), variable - assert np.allclose( - a.calculate("state_pension_age", year), - b.calculate("state_pension_age", year), - atol=1e-4, - ) + for variable in [ + "state_pension_age", + "state_pension_credit_qualifying_age", + "months_since_state_pension_age", + ]: + assert np.allclose( + a.calculate(variable, year), b.calculate(variable, year), atol=1e-3 + ), variable assert list(a.calculate("date_of_birth", year)[1:]) == [ymd(d) for d in births] assert a.calculate("date_of_birth", year)[0] == 0 diff --git a/policyengine_uk/utils/state_pension_age.py b/policyengine_uk/utils/state_pension_age.py index 171fe3a597..ce8d19225d 100644 --- a/policyengine_uk/utils/state_pension_age.py +++ b/policyengine_uk/utils/state_pension_age.py @@ -16,25 +16,11 @@ from policyengine_uk.utils.dates import ( add_months_to_yyyymmdd, - birth_instant_from_age, grid_month, - grid_months_to_yyyymmdd, yyyymmdd_to_grid_months, ) -def date_of_birth(age, months_since_last_birthday, year: int) -> tuple: - """The instant of birth, in grid months, and the day of birth (YYYYMMDD). - - The person was born age years and months_since_last_birthday months before - 6 October of the fiscal year starting in ``year``. The day of birth is the - one starting at or after that instant, so the person's legal age on 6 - October is age. - """ - birth = birth_instant_from_age(year, age, months_since_last_birthday) - return birth, grid_months_to_yyyymmdd(birth) - - def age_attaining_pensionable_age( timetable, birth, birth_date, male_rule ) -> np.ndarray: From dc2ae4e22b0a24de4636517be9e25e351b87c3b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 16:00:42 -0400 Subject: [PATCH 8/9] Pin transitional-cohort examples in the date-of-birth input test Final review of 20e67683d (code lens): the input-path property test rarely exercised has_attained_state_pension_credit_qualifying_age, since a wrong birth instant only changes it for transitional cohorts in their attainment year; a mutant ignoring the date input survived 9 of 10 seeds. - @example a woman born 10 December 1952 in 2015 and a man born 10 November 1960 in 2027; the mutant now fails both. - Assert a woman's SPC qualifying-age attainment equals is_SP_age (SPCA 2002 s.1(6)(a)) on the date path. - Compare the ages and months since State Pension age exactly (atol 1e-9, rtol 0): the date and months inputs agree to the bit. - Docs: a date_of_birth input also moves the Class 4 birthday. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 5 +++-- policyengine_uk/tests/test_date_of_birth.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 32aec4e40b..985576b98a 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -134,8 +134,9 @@ Other programmes test State Pension age in two ways: itself is read as over it at the beginning of that year. With default whole-number ages in a household situation, the inferred birthday is 6 April. Entering an integer State Pension age therefore makes this - boundary reading determine the Class 4 result; fractional ages and explicit - birthday-month inputs can place the birthday on another day. Class 1 employee + boundary reading determine the Class 4 result; fractional ages, explicit + birthday-month inputs and a `date_of_birth` input can place the birthday on + another day. Class 1 employee contributions stop at State Pension age itself (SSCBA 1992 s.6(3)), which the annual model reads as `is_SP_age`. diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index 0502362f31..cc03b266bc 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd import pytest -from hypothesis import given, settings +from hypothesis import example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -264,6 +264,13 @@ def births_and_year(draw): @settings(max_examples=25, deadline=None) @given(case=births_and_year()) +# Transitional cohorts in the year they reach the qualifying age, where a +# wrong birth instant changes has_attained_state_pension_credit_qualifying_age +# but a uniform draw rarely lands: a woman born 10 December 1952 (qualifying +# age 62 years and about 9 months, reached in 2015-16) and a man born 10 +# November 1960 (66 years and 7 months, reached in 2027-28). +@example(case=([(date(1952, 12, 10), False)], 2015)) +@example(case=([(date(1960, 11, 10), True)], 2027)) def test_a_date_of_birth_input_matches_the_same_birthday_by_age(case): """Setting date_of_birth (with the legal age on 6 October) gives the same State Pension age, State Pension Credit qualifying age, statuses, Savings @@ -307,8 +314,15 @@ def test_a_date_of_birth_input_matches_the_same_birthday_by_age(case): "months_since_state_pension_age", ]: assert np.allclose( - a.calculate(variable, year), b.calculate(variable, year), atol=1e-3 + a.calculate(variable, year), b.calculate(variable, year), rtol=0, atol=1e-9 ), variable + # A woman's qualifying age for State Pension Credit is her State Pension + # age (SPCA 2002 s.1(6)(a)), so on the date path both statuses agree. + women = ~a.calculate("is_male", year) + assert np.array_equal( + a.calculate("has_attained_state_pension_credit_qualifying_age", year)[women], + a.calculate("is_SP_age", year)[women], + ) assert list(a.calculate("date_of_birth", year)[1:]) == [ymd(d) for d in births] assert a.calculate("date_of_birth", year)[0] == 0 From dcf39121466f6b76c2e2c391ecdc2d2f5d1223a7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 18:57:53 -0400 Subject: [PATCH 9/9] Correct the 1960 example's qualifying age in a test comment Round-diff review nit: a man born 10 November 1960 reaches State Pension age (and the SPC qualifying age) at 66 years and 8 months (Pensions Act 2014 table 3, 6 November to 5 December 1960), on 10 July 2027, not 66 years and 7 months. Comment only. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_date_of_birth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index cc03b266bc..dc300f06a8 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -268,7 +268,7 @@ def births_and_year(draw): # wrong birth instant changes has_attained_state_pension_credit_qualifying_age # but a uniform draw rarely lands: a woman born 10 December 1952 (qualifying # age 62 years and about 9 months, reached in 2015-16) and a man born 10 -# November 1960 (66 years and 7 months, reached in 2027-28). +# November 1960 (66 years and 8 months, reached on 10 July 2027). @example(case=([(date(1952, 12, 10), False)], 2015)) @example(case=([(date(1960, 11, 10), True)], 2027)) def test_a_date_of_birth_input_matches_the_same_birthday_by_age(case):