Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2480.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix stale construction-time reform wrappers after foreign writes or direct edits while preserving copied scenarios and constructor readings; document mixed-composite replay, indexed-threshold construction and the existing rejection of built overrides whose keys contain no 1 January annual value.
7 changes: 6 additions & 1 deletion docs/book/usage/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,12 @@ A dataset's later years are projected from its first by the economic assumptions

All of this applies when the scenario or reform is passed to `Simulation(...)`. `Scenario.apply(simulation)` on a simulation that is already built edits its annualised values instead, as it always has: a `"2027-04-06.2028-04-05"` key there overrides those dates of the fiscal-year values, so it does not set fiscal year 2027, and the model logs a warning saying so. Pass the scenario when constructing the simulation for dated measures.
The modifier returned by `Scenario.from_reform` behaves the same way on a built simulation for changes that are no longer effective, and raises if an external override key contains no 1 January annual value.
A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites always replay in their requested order; this can append duplicate override-log entries. An external key that contains no 1 January annual value raises with its path and key.

During construction, the already-written statutory changes of an original dated reform keep the constructor's reading while their parameter paths remain untouched by foreign writes or direct parameter edits. This includes a value-equal reform called by a wrapper or a deep-copied scenario. The original modifiers still apply their single-day leftovers in order. If a user modifier edits those statutory paths and then requests the same reform, that request instead overrides the built, annualised values. An external composite replays its complete requested sequence; its children cannot skip a write merely because they equal an original constructor reform. Edits before data load remain foreign when a later modifier runs after data load. This boundary concerns parameter writes, not the different award values that Universal Credit rebalancing intentionally stores.

A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites on a built simulation always replay in their requested order; this can append duplicate override-log entries and change the copy's original construction reading. For example, a dict with a basic-rate window `"2026-06-01.2027-03-31": 0.31` and a VAT single-day key `"2027-01-01": 0.25` gives the 2027 basic rate its unchanged 20% construction value. Replaying its modifier on a clone overrides 1 January 2027 with 31%; the original simulation stays unchanged. Relative `Reform` classes and custom modifiers still run when called on a copy; a modifier that multiplies a parameter by 1.1 applies that multiplication to the copy's current value. When changing policy parameters on a branch, request `get_branch("policy", clone_system=True)`; the default branch shares its tax-benefit system with its parent.

Dated measures on indexed thresholds also need `Simulation(scenario=...)` or `reform=...` for the statutory projection and indexation resume described above. Applying them to a baseline-built simulation overrides only its already annualised values; it does not recalculate the indexed path after the override ends. An external key that contains no 1 January annual value raises with its path and key, including `"2027-04-06"` and `"2027-04-06.2027-12-31"`.

### Creating complex scenarios with simulation modifiers

Expand Down
14 changes: 13 additions & 1 deletion policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
UKMultiYearDataset,
UKSingleYearDataset,
)
from policyengine_uk.utils.scenario import Scenario
from policyengine_uk.utils.scenario import ConstructionReformState, Scenario
from policyengine_uk.data.economic_assumptions import (
extend_single_year_dataset,
reset_growthfactor_uprating,
Expand Down Expand Up @@ -258,6 +258,7 @@ def __init__(
# of this scenario's single-day leftovers while rejecting unreadable
# overrides from a user modifier's separate from_reform call.
self._building_dated_reforms = scenario.dated_reforms if scenario else None
self._construction_reform_state = None

self.branch_name = "default"
self.invalidated_caches = set()
Expand Down Expand Up @@ -295,8 +296,10 @@ def __init__(
)
if before_data_load is not None:
self.apply_scenario_parameters(before_data_load)
self._construction_reform_state = ConstructionReformState(self)
if before_data_load.simulation_modifier is not None:
before_data_load.simulation_modifier(self)
self._construction_reform_state.observe_user_edits(self)
elif after_parameters_before_data:
self.apply_scenario_parameters(after_data_load)
self._refresh_capital_gains_projection()
Expand Down Expand Up @@ -325,6 +328,8 @@ def __init__(
# (including single-day reform-dict keys) leave the data as built.
if after_data_load is not None and not after_parameters_before_data:
self.apply_scenario_parameters(after_data_load)
if self._construction_reform_state is None and self._building_dated_reforms:
self._construction_reform_state = ConstructionReformState(self)

# Universal Credit reform (July 2025). Needs closer integration in the baseline,
# but adding here for ease of toggling on/off via the 'active' parameter.
Expand Down Expand Up @@ -376,10 +381,13 @@ def __init__(
self.baseline._cgt_warning_enabled = False
self.baseline._defer_uc_health_warning = False
self.baseline._building_dated_reforms = None
self.baseline._construction_reform_state = None

self.calculated_periods = []

if after_data_load is not None:
if self._construction_reform_state is not None:
self._construction_reform_state.refresh_framework(self)
if after_data_load.simulation_modifier is not None:
after_data_load.simulation_modifier(self)
self._refresh_capital_gains_projection()
Expand All @@ -389,6 +397,7 @@ def __init__(

self._defer_uc_health_warning = False
self._building_dated_reforms = None
self._construction_reform_state = None
warn_shadowed_lcwra_reform(self)

def _refresh_capital_gains_projection(self) -> None:
Expand Down Expand Up @@ -450,6 +459,9 @@ def set_input(self, variable_name: str, period: Period, value) -> None:

def clone(self, *args, **kwargs) -> "Simulation":
clone = super().clone(*args, **kwargs)
clone._construction_reform_state = copy.deepcopy(
getattr(self, "_construction_reform_state", None)
)
copy_model_values(self, clone)
clone._cgt_input_provenance = copy.deepcopy(
getattr(self, "_cgt_input_provenance", {})
Expand Down
Loading
Loading