Repository navigation
Apply the 6 April 2017 child cutoffs by date of birth - #1904
Conversation
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>
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>
|
Coordination note from #2051 (Child Tax Credit family element, #2050), which adds a fourth reader of the 6 April 2017 cutoff on main.
|
|
Follow-up from #2051's review: when
#2051's |
#1899 (State Pension age by date of birth) merged. Conflicts resolved: - utils/dates.py: main's exact_age_in_months (a fractional age is the exact age) with this branch's birth_instant helpers after it. - months_since_state_pension_age: this branch's shared birth instant, documenting main's fractional-age rule. - child_minimum_guarantee_addition: rank by date of birth over main's is_child_or_qualifying_young_person condition (#1896). - its YAML: main's QYP test and this branch's 5/6 April 2017 cases, with the inert birth_year inputs dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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 <noreply@anthropic.com>
GPT-6.1 Sol review of 86523cf (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 <noreply@anthropic.com>
Main (255 commits) moved State Pension age into utils/state_pension_age and added the State Pension Credit qualifying age (#1907), each deriving the birth from age and months_since_last_birthday. Resolved so every date-of-birth rule shares utils.dates.birth_instant (date_of_birth where given, 0 = not given, invalid input rejected): - state_pension_age and state_pension_credit_qualifying_age take the instant from birth_instant; - months_since_attaining now takes the birth instant and year, and months_since_state_pension_age and has_attained_state_pension_credit_qualifying_age pass it; - meets_savings_credit_age_requirement's "attained 65" uses it too; - utils.state_pension_age.date_of_birth reuses birth_instant_from_age; - the mixed-age saving keeps main's SPC qualifying age condition with this branch's 5 February 1954 date cutoff. Without a date_of_birth input every result is main's. Also narrows the UC born_before reference title (Sol review nit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In-session review of 297a210 (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 <noreply@anthropic.com>
Final review of 20e6768 (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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
Merging on the gates at head CI and mergeability. Independent reviews. These are not GitHub reviews; their outputs are in
What the final check executed
Why this needs no decision from Max. It corrects the model to the cited law. Per Max (2026-10-02), bug fixes that move aggregates merge on gates; the impact is stated in the PR body. Axiom.
|
Fixes #1903
Builds on #1899 (merged 2026-10-03), which added
months_since_last_birthday. This now targetsmainand has main merged in, including #1907's State Pension Credit qualifying age. Its variablesstate_pension_credit_qualifying_ageandhas_attained_state_pension_credit_qualifying_age, its helpermonths_since_attaining, andmeets_savings_credit_age_requirement's "attained 65" test now take the same shared birth instant as State Pension age, so adate_of_birthinput sets them consistently. Without one, every pension-age result is main's, which the impact run confirms record by record. #1907's age-only helperutils.state_pension_age.date_of_birth, left without callers, is removed.Summary
Four rules turn on whether a child was born before 6 April 2017, and a fifth, the Pension Credit mixed-age couple saving, on whether the older partner was born on or before 5 February 1954:
uc_is_child_born_before_child_limituc_individual_child_elementis_CTC_child_limit_exemptchild_minimum_guarantee_additionhas_mixed_age_couple_pension_credit_savingThe first 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 bornageyears andmonths_since_last_birthdaymonths 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 isage. 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, withageas 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 adate_of_birthinput setsstate_pension_age,months_since_state_pension_age,is_SP_ageand each cutoff consistently. Age-based rules still readage. A situation that setsdate_of_birthfor some people gives the others 0, which the helper treats as not given; see the review section. Without an input,state_pension_ageis exactly Set State Pension age from date of birth, including the rise to 67 #1899's.born_beforedate parameters replace the year parameters:gov.dwp.universal_credit.elements.child.limit.born_beforegov.dwp.tax_credits.child_tax_credit.limit.born_beforegov.dwp.pension_credit.guarantee_credit.child.first.born_before, which was a hard-coded2017.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_indexand the Pension Credit child rank now sort bydate_of_birthrather 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_yearis now the calendar year ofdate_of_birth. Its model readers on main were the three child cutoffs and the mixed-age saving; all four now compare the exact date instead.For the child rules, 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 < 2017gave, andbirth_yearis stillperiod - 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, withageas 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:Households with whole ages match the birth-year rule. Exhaustively, for 2015-2035 and ages 0-19:
period - age < 2017;is_CTC_child_limit_exemptagrees, and so doesuc_is_child_born_before_child_limitfor under-16s;birth_year == period - age.This differential test pins "whole-age situations unchanged".
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_birthequals adatetimereference that shares no code withutils/dates.py, though it re-implements the same grid rule;ageon 6 October;birth_yearis its year;Ordering refines age order (Hypothesis).
uc_child_indexis a permutation of 1..n. A child born earlier is always numbered first, and so is an older whole age.Microdata shares match the statute. In a data-built simulation with weights, for 2022, 2024, 2025, 2026 and 2028 and ages 5-12:
birth_yearthe year beforeperiod - ageequals the share of birthdays from 7 October to 31 December;date_of_birth < 20170406.A date input matches the same birthday placed by age (Hypothesis,
births_and_year). For any year from 2015 to 2040, and people of either sex born from 1935 to the year before, settingdate_of_birth(with the legal age) gives the samebirth_year,is_SP_age,has_attained_state_pension_credit_qualifying_age,meets_savings_credit_age_requirement, UC flag and CTC flag as setting the matchingmonths_since_last_birthday.state_pension_age,state_pension_credit_qualifying_ageandmonths_since_state_pension_ageagree exactly (to 1e-9). A woman's SPC qualifying-age attainment equals heris_SP_age. A person left without the input in the same situation readsdate_of_birth0 but gets the results they would with no input at all.A uniform draw rarely reaches the transitional cohorts in their attainment year, where a wrong birth instant would change SPC attainment, so two are pinned as
@examples: a woman born 10 December 1952 in 2015, and a man born 10 November 1960 in 2027. A mutant whose attainment ignores the date input fails on both. The Savings Credit "attained 65" half can't be told apart on this path, because the input check already forces the legal age to equalage.Boundary YAML tests (born 5 against 6 April 2017):
demographic/date_of_birth.yamlageandmonths_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.yamltax_credits.yamlCTC_child_element(2024-25)child_minimum_guarantee_addition.yamlAs a mutation check, reverting either ranking to
-agefails 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 current
main(8090f1c19, which includes #1899 and #1907) and of this branch (297a210f8). The later commits20e67683danddc2ae4e22change no model code: they remove an uncalled helper and touch only tests, docs and changelog. These figures therefore stand at the head. Children in poverty use HBAI's definition (is_hbai_dependent_child).This branch minus main
period - age= 2017), levelThe split between the two causes is measured, not assumed. Every change in Pension Credit, and the UC "mixed-age saving" row, comes from benefit units whose
has_mixed_age_couple_pension_credit_savingchanged. Every one of those lost the saving; none gained it.child_countis infinite from 6 April 2026). The remaining effect is the higher first child element (reg 43) for families whose eldest child is in that half.main compared
birth_year(period less age) with 1954. In data that admits an older member agedperiod - 1954, born from 7 October 1953 to 6 October 1954.Only births on or before 5 February 1954 reached the qualifying age by 14 May 2019, so the fix removes births from 6 February to 6 October 1954.
In 2025-26, 6.56k benefit units (5 survey records) lose the saving: 3.67k lose a Pension Credit payment, 5.77k gain a UC payment (3.25k do both), and one record's payments don't change. The net effect is -£0.05bn Pension Credit and +£0.06bn UC.
These couples also lose Housing Benefit, so their households lose net income overall. Measured over the benefit units whose saving changed (Pension Credit, UC and Housing Benefit), with Council Tax Reduction and household net income taken at household level for their households:
This is why household net income rises by less than UC in 2024-25 and 2027-28. These are also the households entering absolute poverty in the table above. Every household that enters is one of them: 2 before housing costs in 2024-25, and 2 before and 2 after housing costs in 2027-28. This was matched by household id in
saving_hh_aggregate.py.Without the date fix, deriving
birth_yearfrom the exact date would have pushed this rule the other way. Births from October to December 1954 entered it, which a pre-fix run showed as one 10.6k-weight benefit unit gaining £83m of Pension Credit in 2027-28.state_pension_age,months_since_state_pension_age,is_SP_age,state_pension_credit_qualifying_age,has_attained_state_pension_credit_qualifying_age,meets_savings_credit_age_requirementandstate_pension. The data carries nodate_of_birth, so routing them through the shared birth instant changes nothing there.The scripts, CSVs and household-crossing counts are in
~/PolicyEngine/_pr_worktrees/dob-cutoffs-axiom/impact/:v3_main.csv,v3_head.csv,v3_summary.txt,v3_*_flips.npz,v3_saving_*.npz,v3_pa_*.npzandv3_lines_rel_*.txt,v3_saving_hh2.txtand, for the UC recipient-children row,v3_hh_*.npzandv3_saving_hh.txt(whose household columns are withdrawn), fromrun_one_v2.py,flips.py,saving_diag.py,saving_hh_diag2.pywithsaving_hh_aggregate.py,pension_age_diag.pyandlines_rel.py. APROVENANCE.mdthere records the commits and the dataset blob. Every figure is from a real run; none is scaled or interpolated. Earlier runs against #1899's head and against main at325d585e5gave the same child-cutoff figures to within £0.01bn.Caveats
CTC_family_elementpays 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.Removed parameters
gov.dwp.universal_credit.elements.child.limit.start_yearandgov.dwp.tax_credits.child_tax_credit.limit.start_yearheld the year 2017, andgov.dwp.pension_credit.mixed_age_couples.saving_latest_birth_yearheld 1954 (added by #1940). They are replaced by the datesborn_before(20170406) andsaving_latest_birth_date(19540205), and theremovedfragment 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 ispolicyengine-app-v2's mock metadata (app/src/mocks/UK_Metadata.json). A saved policy that changes any of them will stop loading, rather than silently doing nothing. The fragment type isremoved(a minor release), following #1899's removal of the State Pension age year parameters and this repo'sgithub-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:date_of_birthset 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 cutoffutils.dates.birth_instantnow resolves every rule's birth instant: the input where given, otherwise fromageandmonths_since_last_birthday. There are YAML regressions and a Hypothesis property.state_pension_ageandmonths_since_state_pension_agenow share one instant, which gives 66, attained 5 January 2026.removedvsbreakingremoved, following #1899's precedent (see above).age_exemptionreform never lifts the limitA round-2 review approved, on the condition that the tests pass (they do; see "Tests run"). Its one should-fix: a
date_of_birthinput was never checked, so20170229became 1 March, and2017was read as a date in year 1, which put a child over State Pension age. It also wasn't checked againstage. Such inputs now raise aValueError(test_an_invalid_date_of_birth_is_rejectedandtest_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.A final review by GPT-6.1 Sol (Subfleet,
review/hard) approved86523cf3fafter merging main and the mixed-age change. It re-ran all the suites, 280,000 no-input comparisons against main, and 73,049 calendar round trips, and confirmed the 5 February 1954 cutoff is inclusive for both sexes. Its should-fixes, all done inc6db31973:date_of_birthinputs;Its figure nits (rounding, poverty ranges, the Pension Credit population, the saving breakdown, and a relative-line check that had used the absolute line) are corrected above from new runs.
A Sol delta review of
c6db31973approved, with three documentation nits:All three are fixed, the last in
297a210f8. Main then moved 255 commits, so297a210f8merges it and routes #1907's new pension-age helpers through the shared birth instant (see the summary). That merge is what the next review covers.The Subfleet daemon hung, so that review ran in-session: two independent Opus 5.5 reviewers, each with its own lens.
Engine and merge lens: APPROVE. It found:
Its nits are fixed in
20e67683d: the unused age-only helper is removed, the input-path test is extended, and the State Pension docs cover a directdate_of_birth.Claims lens: REQUEST_CHANGES, on this description only. Its points are corrected above from my own re-runs:
birth_yearreaders and changelog wording.A final in-session review of
20e67683dran with the same two lenses.dc2ae4e22, as above, along with the Class 4 docs sentence.map_to, which splits a household's value across its members. Recomputed at household level, it is -£7.4m. The poverty entrants are now explained, and the provenance is completed.Tests run
At head
dc2ae4e22; the head commitdcf391214only corrects a test comment (the 1960 example's qualifying age, 66 years and 8 months):policyengine-core test policyengine_uk/tests/policy -c policyengine_uk, every policy YAML: 2,419 passed.test_date_of_birth.py,test_state_pension_age.py,test_state_pension_credit_qualifying_age.py,test_mixed_age_couple_properties.pyandtest_pension_age_severe_disability_premium_properties.py.has_attained_state_pension_credit_qualifying_ageignore a date input fails both pinned examples.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_increaseandmixed_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) | TheAxiomFoundation/rulespec-uk#370 queued (SI 2019/37 art. 4(1) saving, extended with the 5 February 1954 condition)🤖 Generated with Claude Code