Repository navigation
Read calendar-year series' bare years as calendar years, and make generated-series reforms take effect - #2404
Merged
Merged
Conversation
…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>
…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>
…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
marked this pull request as ready for review
October 14, 2026 17:45
This was referenced Oct 14, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 15, 2026
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
gov.economic_assumptions.yoy_growthhold one value per calendar year, dated 1 January, and processing reads them there.{"…yoy_growth.obr.average_earnings": {"2027": 0.2}}showed 2027 growth of 20%, but moved:householdtree, which is never annualised:financial_assets {"2027"}set 2028.parameter_changeswere affected."2027-01-01"key on these series was a one-day override of the processed value, which nothing built from it read.govparameters. So a change to an uprated household parameter after its last stated value froze the years around it at its last stated value.{"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.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:
gov;calendar_year_seriesmetadata on themselves or an ancestor: theyoy_growth.yamlnode, and theindicesnode built increate_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_changesandReform.from_dict.Scenario.applyon 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
Scenariosum (each operand is canonicalised before the merge). Before, one change was silently dropped.average_earnings {"2027": 0.2}: triple lock April 2028 / April 2029yoy_growth.triple_lock {"2028": 0.05}: weekly nSP 2028triple_lock.outturnfor April 2028){"2030": 500}(£26,000 a year): 2026 / 2030 / 2031Reproducer: 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)year:Y-01-01:1andY-01-01.Y-12-31give identical parameters.parameter_changes.outturnoverride on every route, as a differential test. A changed index uprates what it indexes.sim.baseline,parameters.baselineand later simulations.Review
Four rounds of independent Opus 5.5 review.
Round 1: requested changes. I reproduced each finding and fixed it in e6c115f:
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:
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.Round 3 (fresh reviewer, at f9aa623): requested changes. All of them are fixed in the following commit:
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.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.
merge-treegives the same tree), and main's newtrading_lossinput follows a reformed calendar-year index.__add__found no counterexample. They covered random stages, spellings and bracketings, plus idempotence, purity and an alias-free differential against the pre-fix merge.Scenario.applyon 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_STEPSbinds 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
test_process_time_reforms.py+test_dated_reform_routes.py+test_variable_module_release.py, at 9531e13 (round 4 reviewer's run)Companion: #2405 (data side: the URL dataset cache and the dataset uprating).
axiom: n/a: infra
🤖 Generated with Claude Code