Repository navigation
Set State Pension age from date of birth, including the rise to 67 - #1899
Conversation
The State Pension age parameters held one age per year and stayed at 66 from 2020, so the model never applied the Pensions Act 2014 s.26 rise to 67 for people born on or after 6 April 1960. From 2026-27 every 66-year-old counted as over State Pension age. Encode Pensions Act 1995 Sch 4 para 1 by date of birth, row for row: age_by_birth_date (the age, in months) and day_by_birth_date (the day, where the statute sets one), for women and for men born on or after 6 December 1953, plus rule (1) for men born earlier. A person attains State Pension age on the later of the two. months_since_last_birthday places each date of birth within the year of age, measured at 6 October, the middle of the fiscal year. A fractional age is the exact age; single households use the middle of the year of age; representative microdata spreads each single year of age and sex evenly by weight, so the weighted share over State Pension age is the statutory share (three quarters of 66-year-olds in 2026-27, a quarter in 2027-28, none after). state_pension_age is now the person's own State Pension age, and is_SP_age, the basic/new State Pension split and the Savings Credit age test (SPCA 2002 s.3(1)(a), including its age-65 limb) all follow it. No additional State Pension is paid below State Pension age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A birth instant within a day is the day starting at or after it, so a person's legal age on 6 October is their age and each age is attained at the commencement of the anniversary (Family Law Reform Act 1969 s.9(1)). The time-of-day carry onto the anniversary is gone. - One helper gives exact age in months (float64), capping months since the last birthday a few minutes short of 12 so it never rounds onto the next birthday. Hypothesis found that edge. - Birthdays are spread over the year in any simulation built from data (Simulation.built_from_dataset), not whenever weights exceed a million, so a constituency or local authority filtered from the data is still spread, and filter_dataset carries each person's place into an extract. - Rewrite the old Savings Credit cases from dates of birth, keep one that tests the state_pension_age override, and test the attainment day exactly in the day-level differential test. - male/born_before is a YYYYMMDD number like the scales; labels no longer hardcode the date; the NI reference notes its numbering; the removed fragment and docs say how to reform the timetable now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two boundary cases test how primary and secondary Class 1 split at state pension age, not how that age is derived. Setting is_SP_age as an input keeps them valid under #1899, which derives it from date of birth (a 66-year-old in 2026-27 is then usually over it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reviewed: approve with nits. Working out State Pension age from date of birth (encoded row by row, with the 6 October status date and the weighted spread of birthdays within each year of age) matches the statute and gives the right population shares.
Verified: all 77 rows in the test fixture match the current Sch 4 Part I text on legislation.gov.uk (rules 1–10, tables 1–4, rule 7A). I checked the shares of 66-year-olds over State Pension age by hand: 75% in 2026-27 (born up to 5 July 1960), 25% in 2027-28 (born up to 5 January 1961), and none after that. |
…ers, month arithmetic, birthday defaults Reforms, scenarios and saved policies that name the removed gov.dwp.state_pension.age.male or .female scalars now raise an error naming the birth-date scales that replace them. Month arithmetic uses an explicit month timedelta, so NumPy's generic-timedelta deprecation warning no longer fires. A household that sets months_since_last_birthday for some people gives the rest the six-month default, and a fractional age is the exact age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…phase-in scenario.py: keep main's structural reform application (#1928) and this branch's removed-parameter guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… *args) #1928 replaced the (reform_class, *args) tuple form with a tuple of reforms applied in order, so Scenario.from_reform now rejects it before any parameter is touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed the three review points in
On the merged tree ( |
|
Merged under decision d832 (merge once vahid-ahmadi's review items are addressed, CI is green and a delta review approves) and Max's PE-UK merge-on-gates rule.
|
#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>
Rerun the before/after microsimulation on main (325d585) and this branch merged with it, after #1899 merged. Figures from 2027-28 move slightly because main now projects the flat rates from the triple lock's statutory inputs: the gap to DWP's forecast less payments abroad is -£32.5bn in 2030-31 (was -£33.1bn), and State Pension per recipient grows 17.0% from 2025-26 to 2030-31 (was 16.4%; 21.8% before the fix, was 21.2%). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#576) * Compare State Pension Credit qualifying age on exact age at 6 October PolicyEngine/policyengine-uk#1899 sets state_pension_age from each person's date of birth and makes is_SP_age test exact age on 6 October. The eFRS UK bridge supplied whole age as claimant_age, which would put people born 6 April 1960 to 5 March 1961 under a State Pension age PolicyEngine has them over. - claimant_age is floor(age) + months_since_last_birthday / 12, with PolicyEngine's cap on months since the last birthday. Where that rounds to the State Pension age at PolicyEngine's thousandth-of-a-month resolution, claimant_age is the State Pension age itself, so attaining it on 6 October stays a tie despite float32 storage. - The surface reads months_since_last_birthday; the loader skips it on PolicyEngine UK releases that predate #1899, and the projection then uses whole age, as those releases do. - The qualifying-age query runs on 6 October of the fiscal year. - Projection note and mapping rationales describe the new projection. Tests: the 2026 66-year-old split, float32 ties from PolicyEngine output, Hypothesis properties (agreement with is_SP_age, projection within the rounding band, monotonicity, pre-#1899 fallback) and a differential test against policyengine-uk itself that runs where #1899 is installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: who whole age misjudges, clip wording, engine-backed test - The projection note now says whom whole age would misjudge. That is 66-year-olds past a State Pension age of 66 and some months (born 6 April to 5 July 1960 in 2026-27, 7 October 1960 to 5 January 1961 in 2027-28). In data-built simulations it is also almost everyone in the year of age they reach 66 or 67: PolicyEngine places each birth within a day, and state_pension_age includes the rest of that day. Both were checked against policyengine-uk at #1899 (c46893e2). - The docstring, note and mapping now mention the cap on months since the last birthday, and that 6 October is PolicyEngine's reading day from #1899. - New engine-backed test: runs the real SPCA 2002 s.1 module on the split and float32-tie rows. It skips without an axiom-rules-engine build and rulespec-uk. - The fractional-age test also checks the PolicyEngine model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * State Pension age: fail on a short engine result; cite PolicyEngine and Sch 4 for every upstream claim Review on #576 found two gaps. The engine-backed test compared results with zip(results, rows), so an engine that returned no results or too few passed silently. The comparison now asserts one result per row (and uses zip(strict=True)); regression tests show empty, short and long results all fail. The mapping rationales and the emitted projection note described PolicyEngine UK behaviour (6 October reading day, the 12 - 1e-4 clip, thousandth-of-a-month rounding, within-day birth placement, cohort dates) with no source in this checkout. Each claim now names the file it rests on in policyengine-uk at 0c7ea821 (the merge of #1899, first released in 2.111.2), or Pensions Act 1995 Sch 4 para 1 rule (1) or rule (7) and table 3 for the cohort dates. The unsupported "66.0013" example is dropped. The text also says that the pinned 2.88.56 predates #1899 and tests age >= state_pension_age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * uk mappings: stop " #" from cutting rationales; pin the negative-months clip Review on #576: in a plain YAML scalar " #" starts a comment, so the qualifying_age rationale's "(the merge of #1899, ..." made yaml.safe_load drop everything after "the merge of". The issue references now read PolicyEngine/policyengine-uk#NNNN, with no space before "#". That form also fixes the same truncation in the existing LBTT and LTT rationales ("PolicyEngine-UK #1795" and "#1796"). tests/bridges/test_mapping_rationales.py composes every mapping file and fails on any plain-scalar rationale with text left on its line after the parsed value. It also checks that both State Pension Credit rationales parse through their last sentence. The projection clips negative months since the last birthday to zero, as PolicyEngine does. A new example pins that, because the Hypothesis strategy only draws months from [0, 12]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * State Pension age: cite 2.111.2 for the PolicyEngine fixtures; wrap a long docstring line The split and tie fixtures were labelled with c46893e2, a pre-merge #1899 head. Review recomputed every fixture on policyengine-uk 2.111.2 and got identical state_pension_age and is_SP_age values. The 2.111.2 wheel's #1899 files are byte-identical to the merge commit 0c7ea821. The comments now cite the release and the merge instead. Also wraps a 109-character docstring line in policyengine_uk_exact_age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
PolicyEngine UK held one State Pension age per year, 66 from 2020 (
gov/dwp/state_pension/age/male.yamlandfemale.yaml). So it never applied the Pensions Act 2014 s.26 rise to 67 for people born on or after 6 April 1960. From 2026-27, every 66-year-old counted as over State Pension age, which moves State Pension, Pension Credit, Universal Credit, National Insurance and the HBAI pensioner group.The law sets State Pension age by date of birth: Pensions Act 1995 Sch 4 para 1, as amended by the Pensions Acts 2007, 2011 and 2014. This PR encodes that timetable row for row and works out each person's State Pension age from their date of birth.
How the model represents it
Parameters (
gov/dwp/state_pension/age/). The old per-yearmaleandfemaleages are replaced by:age_by_birth_dateday_by_birth_datemale/age,male/born_beforeThe two scales cover women, and men born on or after 6 December 1953, which is also the Pension Credit qualifying age for everyone (SPCA 2002 s.1(6)). A person attains State Pension age on the later of the two. Every row cites its rule and table and quotes the statutory period.
Variables:
months_since_last_birthday(new). Months since the person's last birthday on 6 October, the middle of the fiscal year. Together withageit places the date of birth. A birth instant within a day is taken as the day starting at or after it, so the person's legal age on 6 October isage, and each age is attained at the commencement of the anniversary (Family Law Reform Act 1969 s.9(1)).ageis read as the exact age.Simulation.built_from_dataset, so also a region or constituency filtered from the data) spread each single year of age and sex evenly over the year. Records are ordered bysplitmix64_uniform(person_id, salt=2)(theuc_deduction_random_drawprecedent) and placed by their share of the group's weight (stratified_uniform), so the weighted share over State Pension age matches the statute to within one record's weight, not merely in expectation. Independent hashed draws would have a standard deviation of about 50k people a year at age 66.filter_datasetcarries each person's value into a household it extracts, so an extracted household keeps the status it had in the full simulation.state_pension_age: now the person's own State Pension age, from their date of birth.months_since_state_pension_age(new): months between attaining State Pension age and 6 October (negative before).is_SP_age: attained by 6 October, so over State Pension age for most of the year.state_pension_type: BASIC if the person reached State Pension age before 6 April 2016 (Pensions Act 2014 s.1(2)), else NEW. Main approximated this from integer ages.meets_savings_credit_age_requirement: reached State Pension age before 6 April 2016 and has attained 65 (SPCA 2002 s.3(1)(a)). Main used the current State Pension age as if it were each cohort's own. Because ages are held fixed across years, that sent Savings Credit to £0 in 2030-31 and excluded men who reached it at 65 in 2015 (e.g. a man aged 75 in 2025). The newsavings_credit/minimum_ageparameter (65) adds the statutory age limb.additional_state_pension: zero below State Pension age. Main kept paying additional State Pension and protected payment to people moved below it.Docs:
docs/book/programs/gov/dwp/state-pension.mdhas a new State Pension age section.Invariants (stated and tested)
test_state_pension_age.py::TestParametersMatchStatuteparses the verbatim statute text (fixture from legislation.gov.uk, cross-checked against a second independent parse: 77/77 rows agree). It rebuilds both scales and the male rule from the text and requires exact equality. It also checks that the statutory periods are contiguous.datetime, sharing no code with the model, covers births every third day from 1945 to 1984, both sides of every statutory boundary, both sexes and 13 fiscal years (2015-20, 2025-28, 2044-46). For each,is_SP_age,state_pension_typeand the Savings Credit age test agree exactly, and the attainment day agrees to within a day. Status is only ever read on the 6th, where the grid is exact.is_SP_age⇔months_since_state_pension_age≥ 0.stratified_uniform(Hypothesis): values lie in [0, 1), are deterministic, don't depend on record order, and the weighted CDF within each stratum is uniform to within half the largest weight.filter_datasetextract keeps each person's value and status.Boundary YAML tests:
state_pension_age.yaml(25 cases, at every rule change, expected values from the day-level reading) andis_SP_age.yaml(15 phase-in cases). The phase-in cases cover mixed-age couples (UC, not Pension Credit), State Pension of zero below State Pension age, and Class 1 NI liability.state_pension_type.yamlandmeets_savings_credit_age_requirement.yamlgain tests at the 6 April 2016 boundaries.Microsimulation impact
Re-measured on the merged head (3 October). Real
Microsimulationruns on the Enhanced FRS 2024-25 (policyengine-uk-data 1.57.4, SHA-25603fe15e4…68d4): main2c942867against this branch ate51faaa4, which merges that main. That main includes the triple lock from its statutory inputs (#1939) and the mixed-age couple fixes (#1940, #1944).¹ Not given: the change would reveal one resting on fewer than 10 sample records.
Households with higher net income: 333k, 414k, 383k and 337k; with lower net income: 283k, 418k, 689k and 856k (2025-26 to 2028-29). The National Insurance row is the model's
national_insurancevariable: employee Class 1 and Classes 2, 3 and 4, not employer contributions. A run on mainaa67bdb4(before #1940 merged) gave every figure in this table to within £3m.Earlier runs (30 September), with the decomposition. Enhanced FRS 2024-25 (policyengine-uk-data-private 1.57.4, snapshot ace89433). Every figure below is from a full microsimulation run of each version: origin/main 34e10d5, this branch at c46893e, and the same code with table 3 and rule 8 held at 66 (the counterfactual that isolates the rise). Scripts and CSVs are kept with the session's review notes.
The rise from 66 to 67 (this branch minus the same code with table 3 and rule 8 held at 66)
Other corrections (exact birth cohorts, the Savings Credit age test, no additional State Pension below State Pension age)
Whole PR (this branch minus origin/main)
Levels on this branch
Caveats:
ni_class_1_employerstopping at State Pension age. Charge employer NI on employees over state pension age #1895 fixes that; employers keep paying after the employee reaches it.is_hbai_pensioner=is_hbai_adult & is_SP_agein Replace generic child and adult flags with each programme's legal definitions #1896, which inherits this fix.additional_state_pension, which splits the reported amount by its data-year type. With ages held fixed across years, a record's data-year type can differ from its period type, so basic/new plus additional can overlap or leave a gap. Switching to the period type would remove about £1bn (2025-26) rising to about £7bn (2030-31) of overlap already on main. That needs its own review, so it's a separate follow-up, not part of this PR.Removed parameters
gov.dwp.state_pension.age.maleand.femaleare removed. The towncrier fragment isremoved(a minor release), following this repo's practice for removed parameters (2.28.1 removed Pension Credit parameters as a patch); downstream packages pin exact versions (policyengine.py pinspolicyengine-uk==2.102.3), so upgrading is always explicit. The fragment says how to express a State Pension age reform now. Known readers outside this repo:packages/microcosm-build/src/microcosm/build/uk_runtime/frs_take_up.pyreadsages.male(instant)andages.female(instant)to bound the UC take-up population. Migration is tracked in microcosm#1069.src/nics_exemption/pipeline.pyreads them forSPA_MALEandSPA_FEMALE. Its working-age bounds need a migration follow-up.src/triple_lock/config.py,STATE_PENSION_AGE_CHANGES, applies 2028–2042 overrides to both removed scalar paths. uk-triple-lock#21 tracks replacing those overrides with the built-in cohort timetable or an explicit cohort-based reform.axiom_oracles/bridges/efrs_uk.pycompares PolicyEngine's wholeagewithstate_pension_age. axiom-oracles#576 prepares the migration and waits for this PR's release; the statutory specification follow-up is rulespec-uk#369.Readers need cohort-specific eligibility from
is_SP_ageorstate_pension_age; reform writers need the birth-date timetable parameters. These paths were checked against the downstream source during this review.axiom: uk/statute/ukpga/1995/26/schedule/4/paragraph/1 TheAxiomFoundation/rulespec-uk#369 queued
🤖 Generated with Claude Code