Skip to content

Apply the 6 April 2017 child cutoffs by date of birth - #1904

Draft
MaxGhenis wants to merge 5 commits into
state-pension-age-67-phase-infrom
date-of-birth-cutoffs
Draft

MaxGhenis wants to merge 5 commits into
state-pension-age-67-phase-infrom
date-of-birth-cutoffs

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1903

Stacked on #1899, which adds months_since_last_birthday. I'll retarget this to main once #1899 merges; the diff here is this change only.

Summary

Four rules turn on whether a child was born before 6 April 2017:

Rule Law Variable
Universal Credit child limit: transitional protection UC Regs 2013 reg 24A(3), in force 6 April 2017 to 5 April 2026 uc_is_child_born_before_child_limit
Higher UC child element for the first child UC (Transitional Provisions) Regs 2014 reg 43 the same variable, via uc_individual_child_element
Child Tax Credit child limit Tax Credits Act 2002 s.9(3A) and (3B); CTC Regs 2002 reg 9 is_CTC_child_limit_exempt
Pension Credit first child addition for the eldest child SPC Regs 2002 Sch IIA para 10 child_minimum_guarantee_addition

All four compared birth_year (period less whole age) with 2017. So everyone aged 9 in 2026-27 counted as born in 2017, whether born in January (before the cutoff) or May (after it). In survey data, the whole cohort born from October 2016 to October 2017 was treated as born after the cutoff, when about half of it was born before.

This PR compares an exact date of birth with 6 April 2017:

  • date_of_birth (new, YYYYMMDD). Each person was born age years and months_since_last_birthday months before 6 October of the fiscal year. The day of birth is the one starting at or after that instant, so their legal age on 6 October is age. It uses Set State Pension age from date of birth, including the rise to 67 #1899's date grid. It can also be set directly, with age as the person's age on 6 October. Every date-of-birth rule then reads one instant of birth through a shared helper, utils.dates.birth_instant, so a date_of_birth input sets state_pension_age, months_since_state_pension_age, is_SP_age and each cutoff consistently. Age-based rules still read age. A situation that sets date_of_birth for some people gives the others 0, which the helper treats as not given; see the review section. Without an input, state_pension_age is exactly Set State Pension age from date of birth, including the rise to 67 #1899's.

  • born_before date parameters replace the year parameters:

    • gov.dwp.universal_credit.elements.child.limit.born_before
    • gov.dwp.tax_credits.child_tax_credit.limit.born_before
    • gov.dwp.pension_credit.guarantee_credit.child.first.born_before, which was a hard-coded 2017.

    Each is 20170406 and cites its provision.

  • Children are ordered by date of birth. UC Regs reg 24B(1) orders children "by reference to the date of birth ... taking the earliest date first", and Sch IIA para 10 names the "eldest". uc_child_index and the Pension Credit child rank now sort by date_of_birth rather than whole age. This only breaks ties between children of the same whole age, but once a cutoff can fall between two such siblings the order matters. With the old ranking, a family with children born 5 and 6 April 2017 could list the later-born child second, and it would get the child element under the limit.

  • birth_year is now the calendar year of date_of_birth. Its only model readers were the three cutoffs above.

Household situations with whole ages are unchanged. A whole age in a situation sits in the middle of the year of age, which is 6 April of the year the person turned that age. So "born before 6 April 2017" gives exactly what period - age < 2017 gave, and birth_year is still period - age. This is tested for every year from 2015 to 2035 and every age up to 19. Only survey data, which spreads birthdays over the year (#1899), moves, along with fractional ages. A fractional age is now read as the exact age on 6 October, as #1899 already does for State Pension age: in 2025, an age of 8.25 places the birth on 6 July 2017, after the cutoff, where the old truncated birth year said 2016. A date of birth can now also be set directly (date_of_birth: 20170405) for a year, with age as the age on 6 October. A value that isn't a calendar date, or that gives a different age, raises an error rather than being silently normalised.

Invariants (stated and tested)

policyengine_uk/tests/test_date_of_birth.py:

  1. Households with whole ages match the birth-year rule. Exhaustively, for 2015-2035 and ages 0-19:

    • born before the cutoff ⇔ period - age < 2017;
    • is_CTC_child_limit_exempt agrees, and so does uc_is_child_born_before_child_limit for under-16s;
    • birth_year == period - age.

    This differential test pins "whole-age situations unchanged".

  2. The date of birth follows the legal age (Hypothesis). For any whole age 0-15, any position in the year (including instants within a day) and any year 2015-2040:

    • date_of_birth equals a datetime reference that shares no code with utils/dates.py, though it re-implements the same grid rule;
    • the legal-age check below is the fully independent one;
    • the person has legal age age on 6 October;
    • birth_year is its year;
    • the UC and CTC flags hold exactly when the date is before 6 April 2017.
  3. Ordering refines age order (Hypothesis). uc_child_index is a permutation of 1..n. A child born earlier is always numbered first, and so is an older whole age.

  4. Microdata shares match the statute. In a data-built simulation with weights, for 2022, 2024, 2025, 2026 and 2028 and ages 5-12:

    • the weighted share of each age born before 6 April 2017 equals the share of that year of age's days before it (about 181/365 for the straddling cohort, 0 or 1 for the others), within the largest weight plus one day;
    • the share with birth_year the year before period - age equals the share of birthdays from 7 October to 31 December;
    • every record's UC and CTC flags equal date_of_birth < 20170406.
  5. A date input matches the same birthday placed by age (Hypothesis). For any births from 1935 to 2020 and any year from 2021 to 2040, setting date_of_birth (with the legal age) gives the same birth_year, is_SP_age, UC and CTC flags as setting the matching months_since_last_birthday, and state_pension_age agrees to within 1e-4 years. A person left without the input in the same situation reads date_of_birth 0 but gets the results they would with no input at all.

Boundary YAML tests (born 5 against 6 April 2017):

File Cases
demographic/date_of_birth.yaml 9: the derivation, a December birthday, a fractional age, the UC and CTC cutoffs reached through age and months_since_last_birthday, a date set for one person only (the parent keeps State Pension age 68, not over it), and a date setting State Pension age exactly (5 January 1960: 66, attained 5 January 2026, 9.032 months before 6 October)
uc_is_child_born_before_child_limit.yaml 6 new: a third child either side of the limit (2025-26); same-age siblings ordered by date of birth; the higher first child element either side (2026-27); a sibling without a date input placed by age
tax_credits.yaml 4 new: the exemption flag and CTC_child_element (2024-25)
child_minimum_guarantee_addition.yaml 3 new: an only child either side, and the eldest of two same-age children (2026-27)

As a mutation check, reverting either ranking to -age fails exactly the two same-age ordering tests.

Microsimulation impact

Enhanced FRS 2024-25 (policyengine-uk-data-private, snapshot ace89433). Each column is a full microsimulation of each code version:

Every figure tabulated below is identical between the two pairs, to within £0.001bn and 1k people. Two things moved between the pairs at once: #1899 merged main, which brought in #1901's Housing Benefit pension-age claims, and this branch added the review fix. So the pairs alone don't isolate the fix. The code does, though: with no date_of_birth input, birth_instant returns the same instant #1899 used. The one untabulated row that moved is relative poverty after housing costs in 2024-25: -192k people rather than -186k, because in the first pair 9 households entered it and in the second none did. That comes from #1901's change to the base.

This branch minus base

2024-25 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31
Children aged in the straddling cohort (period - age = 2017), level k 751 817 921 753 825 959 774
of whom now counted as born before 6 April 2017 (base: 0) k +373 +404 +472 +382 +417 +478 +384
UC children receiving a child element k +109 +46 +0 +0 +0 +0 +0
UC children not receiving one because of the child limit k -91 -38 +0 +0 +0 +0 +0
UC benefit units with a child held back by the limit (base 655k, 696k) k -54 -20 +0 +0 +0 +0 +0
Universal Credit £bn +0.35 +0.16 +0.04 +0.05 +0.06 +0.07 +0.05
Child Tax Credit £bn +0.00 +0.00 +0.00 +0.00 +0.00 +0.00 +0.00
Pension Credit £bn +0.00 +0.00 +0.00 +0.00 +0.00 +0.00 +0.00
Household net income £bn +0.35 +0.16 +0.04 +0.05 +0.06 +0.07 +0.05
Government balance £bn -0.35 -0.16 -0.04 -0.05 -0.06 -0.07 -0.05
People in absolute poverty, BHC k -95 -28 +0 +0 +0 +0 +0
People in absolute poverty, AHC k -59 -0 +0 +0 +0 -9 -1
Children in absolute poverty, BHC k -69 -17 +0 +0 +0 +0 +0
Children in absolute poverty, AHC k -47 -0 +0 +0 +0 -6 -1
Survey households leaving absolute poverty, BHC / AHC households 6 / 4 4 / 1 0 / 0 0 / 0 0 / 0 0 / 3 0 / 2
  • Universal Credit. In 2024-25 and 2025-26 the effect is the child limit: about half the straddling cohort is now transitionally protected. From 2026-27 the limit is gone (child_count is infinite from 6 April 2026), and the remaining effect is the higher first child element (reg 43) for families whose eldest child is in that half.
  • Ordering by date of birth changes Universal Credit and household net income by at most £0.003bn in any year (2029-30), and leaves every poverty count unchanged.
  • Child Tax Credit and Pension Credit don't change. Tax credits end on 6 April 2025 in the model, and 2024-25 CTC in the data is £0.07bn in total. Only 0.5-0.6k Pension Credit benefit units have a child in 2026-2029.
  • Poverty. Poverty counts move in whole survey households, one to ten a year, so the year-to-year pattern is lumpy. Relative poverty, whose line didn't move (checked for 2026-2029), shows the same. Before housing costs it is -194k and -101k people in 2024-25 and 2025-26, then 0 to -76k (the largest from six households, in 2028-29). After housing costs it is -192k and -135k, then 0 to -81k (the largest from a single household, in 2027-28).

The scripts, CSVs, household-crossing counts (flips_output*.txt), poverty lines (lines_*.txt) and a PROVENANCE.md with commits and the dataset blob are in ~/PolicyEngine/_pr_worktrees/dob-cutoffs-axiom/impact/. Every figure is from a real run; none is scaled or interpolated.

Caveats

  • One record over several years. Ages are held fixed across years in the data, so a record's date of birth moves a year later each period. Transitional protection is therefore a property of each period's cohort, not of a child followed over time.
  • Siblings of the same age. Set State Pension age from date of birth, including the rise to 67 #1899 places each person in their year of age independently, by weight within age and sex. Twins therefore get different dates of birth and can fall either side of the cutoff. The multiple-birth exception (UC Regs 2013 reg 24A(1)(b)(ii) and Sch 12 para 2) isn't modelled either way.
  • CTC family element. Tax Credits Act 2002 s.9(2)(a) limits the CTC family element to claimants responsible for a child born before 6 April 2017, but CTC_family_element pays it to every CTC claimant. That is a separate, pre-existing gap for 2017-18 to 2024-25, and it's queued as a follow-up; the Axiom side is in Encode TCA 2002 s.9(2)(a), (3A), (3B) and CTC Regs 2002 regs 7(2)–(2A) and 9 (family element and child limit by date of birth, 2017-18 to 2024-25) TheAxiomFoundation/rulespec-uk#372.
  • Other exceptions. The other statutory exceptions to the limits (UC Regs 2013 Sch 12: adoption, non-parental caring arrangements, non-consensual conception; CTC Regs 2002 regs 10 to 14) aren't modelled.

Removed parameters

gov.dwp.universal_credit.elements.child.limit.start_year and gov.dwp.tax_credits.child_tax_credit.limit.start_year held the year 2017. They are replaced by the born_before dates, and the removed fragment says how to write a reform now. In the local checkouts of policyengine.py, policyengine-api, policyengine-api-v2, policyengine-app-v2, microcosm and policyengine-uk-data, the only other mention is policyengine-app-v2's mock metadata (app/src/mocks/UK_Metadata.json). A saved policy that changes either parameter will stop loading, rather than silently doing nothing. The fragment type is removed (a minor release), following #1899's removal of the State Pension age year parameters and this repo's github-prs.md; downstream packages pin exact versions.

Independent review

An Opus 5.5 reviewer (Subfleet, review/standard) requested changes. Each finding and what was done:

Finding Severity Resolution
A date_of_birth set for one person gave the others 0, so a 35-year-old parent got State Pension age −1925 and counted as over it; unset siblings ranked first and counted as born before the cutoff blocking Reproduced. utils.dates.birth_instant now resolves every rule's birth instant: the input where given, otherwise from age and months_since_last_birthday. There are YAML regressions and a Hypothesis property.
A date input inconsistent with the age grid gave a State Pension age measured from two births (5 January 1960 gave 65.75) should-fix Reproduced. state_pension_age and months_since_state_pension_age now share one instant, which gives 66, attained 5 January 2026.
"Household situations unchanged" does not hold for fractional ages should-fix Now scoped to whole ages in the body and changelog. The UC flag was added to the household differential test.
Poverty-crossing and poverty-line figures had no saved output should-fix Saved, with provenance.
removed vs breaking should-fix Kept removed, following #1899's precedent (see above).
Invariant 4's year list; reference independence; revoked regs 24A and 24B nit Corrected here. The code comment and rulespec-uk#371 now note that reg 24B was revoked from 6 April 2026.
The UC age_exemption reform never lifts the limit pre-existing Split into a separate follow-up.

A round-2 review approved, on the condition that the tests pass (they do; see "Tests run"). Its one should-fix: a date_of_birth input was never checked, so 20170229 became 1 March, and 2017 was read as a date in year 1, which put a child over State Pension age. It also wasn't checked against age. Such inputs now raise a ValueError (test_an_invalid_date_of_birth_is_rejected and test_a_date_of_birth_that_contradicts_age_is_rejected). Its wording nits, the confounded before/after comparison and the BHC/AHC mix-up in the poverty sentence, are corrected above.

Tests run

  • YAML: policyengine-core test on baseline/demographic, baseline/finance/benefit/family, baseline/gov/dwp and reforms/parametric/two_child_limit: 422 passed.
  • pytest: test_date_of_birth.py (9 passed at the head). Set State Pension age from date of birth, including the rise to 67 #1899's test_state_pension_age.py passed 24 of 24 at ef35551d, including the day-by-day statute reference; the later commit only adds input checks, which never run without a date_of_birth input.
  • Not run locally: the full suite, which CI runs once this targets main.

axiom: uk/regulation/uksi/2002/1792/schedule/IIA encoded-correct (takes the eldest child's born-before-6-April-2017 status as an input judgment; companion tests post_2017_child_has_no_eldest_child_increase and mixed_children_with_pre_2017_disabled_and_severely_disabled_amounts) | TheAxiomFoundation/rulespec-uk#371 queued (UC Regs reg 24A(3) and 24B, SI 2014/1230 reg 43, reg 36 first-child row) | TheAxiomFoundation/rulespec-uk#372 queued (TCA 2002 s.9(2)(a), (3A), (3B); CTC Regs regs 7(2A) and 9)

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits September 30, 2026 06:44
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits September 30, 2026 06:58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Coordination note from #2051 (Child Tax Credit family element, #2050), which adds a fourth reader of the 6 April 2017 cutoff on main.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Follow-up from #2051's review: when CTC_family_element moves to birth_day, three of #2051's YAML cases (policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml) need date_of_birth inputs instead of birth_year, because the helper ignores entered birth years:

  • "a child aged 7 with an entered birth year of 2016";
  • the two "birth year entered for one child only" cases.

#2051's birth_year > 0 fallback in the formula also goes away. Its property test generates entered birth years too (birth_year_offset in test_ctc_family_element_properties.py), so that generator would switch to date_of_birth.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant