Skip to content

Read calendar-year series' bare years as calendar years, and make generated-series reforms take effect - #2404

Merged
MaxGhenis merged 7 commits into
mainfrom
reform-dict-process-time-params
Oct 14, 2026
Merged

MaxGhenis merged 7 commits into
mainfrom
reform-dict-process-time-params

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Since #2305, every reform route writes onto the statutory timeline and reprocesses, so a reform dict on the triple lock minimum rate now reaches the State Pension. The cases below were still wrong on main 714b143. Each was verified by running code.

What was wrong

  1. Bare years on calendar-year series landed a year late.
    • The growth rates in gov.economic_assumptions.yoy_growth hold one value per calendar year, dated 1 January, and processing reads them there.
    • A bare-year key named the fiscal year from 6 April, so it reached the next year's value. {"…yoy_growth.obr.average_earnings": {"2027": 0.2}} showed 2027 growth of 20%, but moved:
      • the 2028 earnings index;
      • the May to July 2028 earnings input;
      • the April 2029 triple lock.
    • The same happened on the household tree, which is never annualised: financial_assets {"2027"} set 2028.
    • Both the reform dict and parameter_changes were affected.
    • A "2027-01-01" key on these series was a one-day override of the processed value, which nothing built from it read.
  2. Uprated household parameters froze around a change. Prior law's projected path was recorded only for gov parameters. So a change to an uprated household parameter after its last stated value froze the years around it at its last stated value.
    • Example on main: {"household.poverty.absolute_poverty_threshold_bhc": {"2030": 500}} held the line at its 2024 level from 2025 to 2030, 5.5% low in 2026, and applied the change in 2031 instead of 2030.
    • An interval key held the years before 2030 at the 2024 level, and restarted the projection from that level after 2030.
    • An earlier commit of this branch also sent 1 January keys this way; the review caught it.
  3. Changes to generated series moved nothing downstream. Generated series are the triple lock rate, the indices, the lagged series and the September CPI uprating rate. Changes to them were applied to the annualised tree after processing, so nothing built from them followed: a 5% triple lock rate for 2028 left the State Pension at £256.98.
  4. Labour supply alias paths were ignored. Deprecated gov.simulation.labor_supply_responses.* paths, still sent by the legacy app's sliders, edited a copy no formula reads.

Fix

  • Calendar-year reading. On calendar-year series, a bare year, or a key dated its 1 January, names that calendar year. Calendar-year series are:

    • parameters outside gov;
    • parameters with calendar_year_series metadata on themselves or an ancestor: the yoy_growth.yaml node, and the indices node built in create_economic_assumption_indices.

    Every other parameter reads every key exactly as before. A differential over 200 sampled statutory gov parameters and key forms checks this; the independent review ran the full differential described under invariant 5.

  • Household projections. Prior law's projected path is now recorded for the household tree too.

  • Generated series. A change to a generated series is written just after the generation step that creates it (GENERATION_STEPS), so the later steps and uprating use it. This works on the reform dict, parameter_changes and Reform.from_dict. Scenario.apply on a built simulation keeps its documented legacy reading.

  • Aliases. Reform dicts canonicalise the alias paths. Changes given by both a path and its alias now merge in order, as two changes to one path do: in one dict and across a Scenario sum (each operand is canonicalised before the merge). Before, one change was silently dropped.

Pensioner (full new State Pension) main this branch
average_earnings {"2027": 0.2}: triple lock April 2028 / April 2029 0.025 / 0.200 0.200 / 0.025
yoy_growth.triple_lock {"2028": 0.05}: weekly nSP 2028 £256.98 £263.25 (= triple_lock.outturn for April 2028)
BHC poverty line, {"2030": 500} (£26,000 a year): 2026 / 2030 / 2031 £22,447.88 / £22,447.88 / £26,000 £23,744.92 / £26,000 / £26,216.21 (prior law outside 2030)
CPI index 2029 ×1.1: BHC absolute poverty line 2029 unchanged ×1.1 (2028 and 2030 unchanged)

Reproducer: policyengine_uk/tests/test_process_time_reforms.py::test_the_growth_shown_for_a_year_is_the_growth_used_for_it
Reproducer: policyengine_uk/tests/test_process_time_reforms.py::test_a_triple_lock_rate_set_by_reform_uprates_the_state_pension
Reproducer: policyengine_uk/tests/test_process_time_reforms.py::test_a_year_of_an_uprated_household_parameter_leaves_the_others_on_prior_law

Invariants (tested in test_process_time_reforms.py)

  1. Inputs reach what is built from them.
    • The minimum rate and earnings growth set the triple lock by its rule and the State Pension by that rate. This holds on the reform dict and both Scenario timings.
    • Property: for any growth rate and year, April Y+1's rate is the highest of the minimum, the May to July input and the September CPI. The May to July input is calendar growth plus the OBR gap, and both inputs are rounded to 0.1pp.
  2. A year is one year on a calendar-year series.
    • A bare year, its 1 January, year:Y-01-01:1 and Y-01-01.Y-12-31 give identical parameters.
    • Property: for every growth series, the index grows by the shown rate in the shown year and by current law's in the neighbouring years.
  3. Prior law around a change.
    • An uprated household parameter changed for one year keeps every other year on current law's path. Tested for three key forms on the reform dict and parameter_changes.
    • A change ending before a series' first stated value resumes with that value, as backdating gives. A stated null stays null.
  4. Generated series take effect. A triple lock rate set by reform equals the statutory outturn override on every route, as a differential test. A changed index uprates what it indexes.
  5. Ordinary reforms are unaffected. Differential over 200 sampled statutory gov parameters and key forms: the readers give identical assignments with and without the calendar-year predicate. The independent review also found zero differences between main and this branch across 1,550 gov parameters × 12 years × 3 routes.
  6. Monotonicity. The April 2028 State Pension is non-decreasing in the minimum rate.
  7. Baselines stay current law. This covers sim.baseline, parameters.baseline and later simulations.

Review

Four rounds of independent Opus 5.5 review.

Round 1: requested changes. I reproduced each finding and fixed it in e6c115f:

  • the household projection gap;
  • the alias collision;
  • the test stand-in's signature;
  • the calendar-aware warning;
  • test oracles and the dead branch;
  • the docs.

Round 2 (at e6c115f): APPROVE. It re-verified every fix and reran the full differential (1,550 gov parameters × 12 years × 3 routes, zero differences). It raised two low findings, now fixed:

  • R2-1. A key two years before a pre-2015 series starts hit 1 + None. Now such a change resumes with the series' first value, and keys from 2005 to 2008 all work; main itself failed at 2006.
  • R2-2. Two scenarios that spelled a labour supply path differently raised an error when combined. Their changes now merge in order.

Round 3 (fresh reviewer, at f9aa623): requested changes. All of them are fixed in the following commit:

  • Medium. Scenario.__add__ merged parameter changes by their literal paths, so a three-step sum spelling a labour supply parameter two ways could resurrect a change a scalar had replaced. Each operand is now canonicalised before the merge. A parametrised test covers four stage patterns in both spelling orders and matches the one-spelling sum; 6 of its 8 cases fail without the fix.
  • Low. The changelog and this body called the alias collision an error.
  • Low. The threshold-indexation note missed the 1 January exception.

The reviewer also ran a differential of the new resume rule against the old one: 68,588 timeline comparisons, zero differences wherever a value was in force at the stop date.

Round 4 (at 9531e13): APPROVE.

  • Fixes and merge. All round-3 fixes were re-verified. The merge from main is clean (merge-tree gives the same tree), and main's new trading_loss input follows a reformed calendar-year index.
  • Canonicalisation. 3,000-case property tests of the canonicalisation in __add__ found no counterexample. They covered random stages, spellings and bracketings, plus idempotence, purity and an alias-free differential against the pre-fix merge.
  • One low finding (R4-1), left to a follow-up. Scenario.apply on a built simulation does not canonicalise alias paths, as it did not before this PR either. Nothing outside the tests calls it.

One unverified note remains. GENERATION_STEPS binds the generation steps at import, so monkeypatching a step's module name no longer reaches processing. The review found no caller that does so, in this repo, policyengine.py or policyengine-api.

Tests

Run Result
test_process_time_reforms.py + test_dated_reform_routes.py + test_variable_module_release.py, at 9531e13 (round 4 reviewer's run) 153 passed
At f9aa623: those two plus the threshold indexation, lagged series, triple lock, no-economic-assumptions and reform representation files 322 passed
The 65 existing test files that touch reforms, parameter processing, growth series, indices or aliases (before the review fixes) 1,308 passed; 1 failed (the stand-in signature, fixed)

Companion: #2405 (data side: the URL dataset cache and the dataset uprating).

axiom: n/a: infra

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 14, 2026 02:04
…ed-series reforms where they are generated

A bare-year reform key on gov.economic_assumptions.yoy_growth (and on
the indices, and on the household tree, which is never annualised) named
the fiscal year from 6 April, so it reached the next calendar year's
value: a 2027 earnings growth rate moved the 2028 index and the 2029
triple lock while showing as 2027's. It now names the calendar year.

Changes to parameters processing generates (the triple lock rate, the
indices, the lagged series) were applied to the annualised tree after
processing, so nothing derived from them followed. They are now written
just after the step that generates them. Deprecated labour supply alias
paths in reform dicts now reach the parameter they copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Example, property and differential tests: the minimum rate and earnings
growth move the triple lock by its rule and the State Pension by that
rate on every route; every key for a year of growth gives the same
parameters and the shown growth is the growth used; a triple lock rate
set by reform equals the statutory outturn override; ordinary parameters
read every key as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 14, 2026 02:35
…stand-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h set twice, name the right year in warnings

Prior law's projected path was recorded for gov parameters only, so a
change to an uprated household parameter past its last stated value
froze the years around it (the poverty line 5.5% low in 2026 and 14.4%
low in 2031 for a 2030 change). It is now recorded for the household
tree too, which also fixes bare-year and interval keys there.

A reform setting a labour supply parameter by both its path and its
deprecated alias dropped one change silently; it is now an error. The
single-day key warning names the calendar year on calendar-year series.
Tests: an uprated household parameter on every key and route, the alias
collision, Scenario.apply and a reprocessing modifier on a generated
series, Reform.from_dict against an oracle. Docs and changelog corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 14, 2026 07:16
…value, merge a path with its alias

A change ending before a series' first stated value (before 2015, where
nothing is backdated) resumed with nothing, so building the index from a
growth rate keyed two years early hit 1 + None; it now resumes with the
first stated value, as backdating gives, and a stated null stays null.
Two scenarios spelling a labour supply path differently now merge their
changes in order instead of raising. Doc wording: single-day keys and the
threshold indexation note name their exceptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… merging

Scenario.__add__ merged parameter changes by their literal paths, and
canonicalisation came later, so a scalar given by a labour supply alias
did not replace an earlier change given by the canonical path, and a later
dict then merged the replaced change back in. Each operand is now keyed by
canonical paths before the merge, so a sum spelling a parameter two ways
gives what the same sum spelt one way gives. The threshold indexation
note and the changelog name the 1 January and merge rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 14, 2026 17:45
@MaxGhenis
MaxGhenis added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 655b594 Oct 14, 2026
16 of 18 checks passed
@MaxGhenis
MaxGhenis deleted the reform-dict-process-time-params branch October 14, 2026 18:29
MaxGhenis added a commit that referenced this pull request Oct 15, 2026
…ards

Brings in #2277, #2391, #2402, #2404, #2405, #2407, #2411, #2413, #2415,
#2418, #2419 and #2420. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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