Skip to content

Apply dated reforms on the statutory timeline on every route - #2305

Merged
MaxGhenis merged 8 commits into
mainfrom
w1-budget-reform-contract
Oct 12, 2026
Merged

MaxGhenis merged 8 commits into
mainfrom
w1-budget-reform-contract

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2304
Fixes #2188
Fixes #2252
Fixes #2167

Review: GPT-6.1 Sol rounds 1 and 2 requested changes. Every finding is fixed (db9dcb0, 603fecb), and round 3 approved 603fecb. Its four low-severity notes are going into a follow-up PR.

What this changes

Every reform route now writes its dated changes onto the statutory parameter timeline (the parameters as loaded from YAML), then processes them as current law is processed: uprating, threshold indexation, then fiscal-year annualisation by each parameter's rule.

  • Reform dicts (Simulation(reform=dict), Scenario.from_reform(dict)) and Reform.from_dict classes used to edit the already-annualised tree. So a change from 6 April 2027 first showed in fiscal year 2028, and derived parameters stayed on the baseline: aligned NICs thresholds, and anything uprated from a changed CPI forecast.
  • Bounded changes now resume prior law's projected path after the stop date. Before, the last stated value was restored, which froze the next year of an uprated or indexed parameter (UC SINGLE_OLD = £500 for 2027 gave £424.90 in 2028).
  • Timing. Scenario parameter changes now run straight after the data load. That is before the UC rebalancing inputs (UC rebalancing reforms don't take effect unless the Scenario applies before the data load #2252) and the parameter-gated structural reforms (the second half of Structural-trigger parameters are sampled at a fixed instant, so reforms gating structural changes can silently fail to activate #1800) read the parameters, and before the scenario's modifier, so its edits are not discarded. In 2.125.1 a scenario carrying a modifier that sets a parameter and parameter changes lost the modifier's value (the dashboard's 8.75% dividend baseline reverted to 10.75%); it is now kept in either order and either timing.
  • Scenario.apply on a built simulation keeps main's semantics: it edits the annualised parameters and never rebuilds them, so a dated key there does not set its fiscal year, and it logs a warning saying so. Constructing the simulation with the scenario (or reform=) is the statutory route.
  • Scenario parameter changes leak into the baseline parameter tree, so baseline_vat shows no change #2188. The reprocessed tree keeps current law's parameters.baseline child.

Each route keeps its documented reading of period keys. Single-day reform-dict keys keep their legacy one-day meaning on the annualised tree, with a ReformPeriodWarning when the key can change nothing. The table is in policyengine_uk/utils/dated_reforms.py.

Verified so far (by running the code)

Case main this branch
Basic rate 21% from 2027-04-06: income tax 2027 on £30k £3,486.00 (no change) £3,660.30 (+£174.30)
UC SINGLE_OLD £500 for 2027-04-06.2028-04-05, dict: FY27 / FY28 434.67 / 500.00 500.00 / 443.36 (prior law)
Same as parameter_changes bare year 2027: FY28 / FY29 424.90 / 433.40 443.36 / 452.23
PA frozen 2031–33, dict: primary threshold 2031–33 246.73 / 251.73 / 256.92 241.73 (follows the allowance)
Protected LCWRA amount £500/month for 2027, dict £5,259.27 (ignored) £6,000.00
Marriage allowance structural switch, dict £0 £1,260

The 20 existing reform, fiscal-year, threshold-indexation, UC rebalancing, behavioural and code-health test files pass locally (2,452 passed).

Prior law around a change

Outside [start, stop] a change leaves prior law in force:

  • Where the timeline states values, they apply on their dates, and the value in force at stop resumes the next day.
  • Where it states none (the future comes from uprating or indexation), prior law's projected path applies: the values the parameter takes with the reform's other changes but without its own. A first processing pass computes them when the reform changes anything else, such as a CPI forecast. They are written in as stated values before the change is overlaid. That covers both the years between the last stated value and a change that starts after it, and the years after a bounded change. Without this step, a UC change from 2028 lost the 2027 uprating, because core uprating extends only from the latest entry. The oracle property found that case.

Whether indexation should instead resume from a changed statutory amount (ITA 2007 s57 indexes "the previous tax year's" allowance) is a methodology call under cos d1344. With the default, an extended PA freeze returns to the baseline path in 2034. The contract runner records the statutory expectation as pending.

Review

GPT-6.1 Sol round 1 (on 48c1417) requested changes. I ran each predicted reproduction, confirmed it, then fixed it.

Finding Before After
A modifier that reprocesses the parameters (no_economic_assumptions, reindex_benefit_cap), combined with a dict reform reform dropped (rate 0.20) kept (0.21)
Scenario.apply on a simulation that already has a reform first reform dropped both kept
PA freeze 2031–35 plus a one-year primary threshold change: threshold 2032–34 251.73 / 256.92 / 262.12 (unfrozen) 241.73 (aligned with the frozen allowance)
A change starting before a parameter's first stated value (found by a new boolean property) lost the backdated value prior law kept
_projected_timelines_cache shared lists with the module-level system's tree aliased a copy
Mixed single-day and dated keys on one parameter silent order change documented; ReformPeriodWarning, also logged

How the fixes work:

  • Changes survive rebuilds. The tax-benefit system keeps its dated changes and legacy overrides, and every rebuild writes them again, starting from the raw tree as the simulation left it. process_parameters() builds parameters.baseline from the tree as it was before the changes.
  • Per-parameter projection. Prior law is now projected for each parameter with the other parameters' changes written, repeated until the projections stop changing.
  • Memoised rebuilds. Rebuilt parameters are memoised by their changes.

GPT-6.1 Sol round 2 (on db9dcb0) requested changes. Each reproduction was run before and after the fix:

Finding Before After
A later rebuild after a structural reform that adds a parameter (reform=(add_bonus, {"test_budget.bonus": 7}), then Scenario.apply) ValueError: Could not find the parameter test_budget.bonus kept (7)
no_economic_assumptions, then an unrelated change: PA 2035 13,910 (assumptions back) 12,570
Scalar Scenario(parameter_changes=…).apply, then a dated change reverted to 0.20 kept (0.21)
A list-valued cached tree mutated through one system the next system got the mutated list each system gets its own copy
parameter_changes plus a dated reform for the same path: construction vs Scenario.apply 0.22 vs 0.21 the same precedence
A change on an unknown path, then a valid change the invalid change was replayed and failed again rolled back
Prior law that does not settle within the pass limit a partial projection was returned and memoised ValueError
A float subclass with a misleading repr as the memo key another reform's value returned only plain int/float/bool/str values are memoised

Build time for a reformed household simulation (host load average over 300, so absolute times are inflated):

first build repeat
main about 1.0 s about 1.0 s
this branch 2–4 s about 1.0 s

Invariants (tested in test_dated_reform_routes.py)

  1. Statutory timeline. A change holds on its dates. Outside them, prior law applies.
  2. Annualisation. Each annual value equals an independent oracle applied to that timeline: the value at 30 April, or the day-weighted average for fiscal_year_blend parameters. Checked by Hypothesis sampling over rate, uprated, indexed and blended parameters, including two-change schedules in either order.
  3. Route equivalence. start.stop keys give identical parameters and income tax through the reform dict, parameter_changes, Reform.from_dict and both Scenario timings. Sampled.
  4. Documented readings. These are kept: bare year (dict: that year; Reform.from_dict: onwards), single-day dict keys, scalars, unknown paths, and parameters added by a structural reform in the same tuple.
  5. Baseline isolation. sim.baseline, parameters.baseline (Scenario parameter changes leak into the baseline parameter tree, so baseline_vat shows no change #2188, vat − baseline_vat on five routes), later simulations, query order, the processed and statutory caches, and a fresh process.
  6. Derived parameters follow. The aligned NICs thresholds follow a PA freeze, and a September CPI change moves PA, BRL and PT by s57(3)/s21(3) arithmetic.
  7. Timing. Reforms after the data load reach the UC rebalancing inputs (UC rebalancing reforms don't take effect unless the Scenario applies before the data load #2252) and parameter-gated structural reforms.

Tests

Merge order with #2242

#2242 adds add_uc_standard_allowance_uprating to process_parameters. Either PR can land first; the second keeps that step after add_threshold_indexation in process_parameter_tree, as agreed with its session.

axiom: n/a: reform-route handling and parameter processing; no policy provision changes.

🤖 Generated with Claude Code

Reform dicts and Reform.from_dict classes now write their changes onto the
parameters as loaded from YAML, which are then processed as current law is
(uprating, threshold indexation, fiscal-year annualisation). Before, they
edited the already-annualised tree, so a change from 6 April missed that
fiscal year and derived parameters stayed on the baseline.

A bounded change with no stated value after it resumes prior law's projected
path rather than restoring the last stated value, which froze the next year
of an uprated parameter. Scenario parameter changes now come right after the
data load, before the UC rebalancing inputs and parameter-gated structural
reforms read the parameters, and before the scenario's modifier. The
reprocessed tree keeps current law's baseline child (#2188).

Fixes #2304, #2188, #2252, #2167.

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

A change starting after a parameter's last stated value lost the uprating
or indexation between that value and the change, because the generators
extend a timeline only from its last stated value. Prior law's projected
path is now written in first wherever a change reaches the projected part
of the timeline. Single-day reform keys are logged as well as warned, since
some variable modules switch warnings off when they load.

Adds test_dated_reform_routes.py: the Budget cases on four routes,
documented readings, an independent annualisation oracle checked by
Hypothesis sampling, route differentials, prior law under the reform's other
changes, and baseline isolation (sim.baseline, parameters.baseline, query
order, module caches, a fresh process).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 11, 2026 16:07
Adds a Dated changes section to the scenarios guide covering the
statutory-timeline processing, each route's period-key reading, prior law
around a change, and when changes are applied relative to data load. Fixes
the UC rebalancing example whose single-date key changed nothing (#2252),
and a stale personal allowance path in the phasing example.

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

Review fixes (GPT-6.1 Sol round 1), each reproduced before fixing:

- A modifier that reloads and reprocesses the parameters
  (no_economic_assumptions, reindex_benefit_cap) discarded the reform, and
  Scenario.apply dropped changes a simulation already had. The system now
  keeps its dated changes and legacy overrides, and every rebuild writes
  them again. process_parameters builds the baseline copy from the
  parameters as they were before the changes.
- Two changes that both reach the projected part of their timelines did
  not see each other: a one-year primary threshold change inside an
  allowance freeze resumed the unfrozen path. Prior law is now projected
  per parameter, with the others' changes, to a fixed point.
- A change starting before a parameter's first stated value lost that
  value's backdating (found by a new boolean property). The backdated
  value is written as prior law first.
- The projection cache is a copy, no longer sharing lists with the
  module-level system's tree.
- Rebuilt parameters are memoised by their changes, so repeated
  simulations of one reform skip the rebuild.
- Mixed single-day and dated keys on one parameter warn that single-day
  keys apply last.

Tests: each of the above, a differential against editing the annualised
parameters for calendar-year changes, and oracle properties for a boolean
and a null-valued preserve_calendar_dates parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 11, 2026 19:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o, and roll back failed changes

Review round 2 (GPT-6.1 Sol, review-uk-r3-out.md):
- Scenario.apply keeps main's semantics: it edits the annualised
  parameters and never rebuilds, so a structural reform's parameters and
  a modifier's edits survive it. Later rebuilds start from the raw tree
  as the simulation left it.
- The rebuild memo keys only plain scalar values, and returns copies.
- Construction and Scenario.apply give parameter_changes and dated
  reforms the same precedence.
- A change that fails leaves the system as it was.
- Prior law that does not settle within the pass limit is an error, not
  a cached partial projection.

Also tests W5's shape: one scenario with a modifier that sets a
parameter and parameter changes, in either order and either timing,
matches the reform dict with both (on 2.125.1 the modifier's 8.75%
dividend rate reverted to 10.75%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 12, 2026 03:04
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Hand-off to the UK hub (W1, session local_37646626-ec63-43ad-a47b-7752ce411a5c)

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

UK hub landing audit for #2305 (W1, Budget critical path).

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