Repository navigation
Run each part's modifier at its own time in a sum of scenarios - #2407
Merged
Merged
Conversation
Scenario.__add__ dropped applied_before_data_load, so every sum was applied after the data load: no_economic_assumptions + reform froze the parameters after the data had been uprated with growth. A sum now keeps its parts when they are applied at different times, and Simulation applies the parts applied before data load before it builds the data and the rest after (Scenario.parts_by_data_load). The sum's fields still hold the whole sum. A sum of scenarios that are all applied before data load is applied before it, and a scenario that changes nothing has no timing of its own. Fixes #2406. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge of parameter changes is not associative (a value, then a dict of periods, then another dict), so a mixed sum keeps its two operands and splits through them, and each part's changes are those `+` gives with the same bracketing. Operands are kept as they were when added: their changes are copied, and their modifiers are the objects the sum's chained modifier calls. Tests (MOCK datasets): no_economic_assumptions + reform, in either order, freezes the data and the parameters as no_economic_assumptions alone does and applies the reform, on single-year and multi-year datasets and on a situation; a macro scenario applied before data load still reaches the data when a reform is added; a data-editing modifier applied after data load still sees the data; a simulation runs each part once at its own time (property); and the split partitions the parts in order, follows the bracketing, keeps Scenario() as an identity and leaves the sum's fields as the previous + made them (properties, with a copy of the previous + as reference). Docs and changelog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it out of the URL dataset cache Review r1 (Opus) found two regressions in the split by timing. The after-load part's parameter changes rebuilt the parameters after the before-load part's modifier had run, which discarded that modifier's direct edits (a structural Reform class applied before data load, or a Parameter.update modifier). And a simulation with a part applied before data load cached its projected dataset under the plain hf:// or gs:// key, so its baseline and later simulations got frozen data (as no_economic_assumptions alone already did on main). A sum with parts applied at different times now applies all of its parameter changes, as + merged them, before the data is built and before any modifier; only its modifiers are split by timing. That keeps modifier edits, gives the precedence Scenario.apply gives, and removes the copies of operands the bracketing-aware split needed. A simulation with a part applied before data load neither reads nor writes the URL dataset cache. Scenario.__deepcopy__ shares one memo between fields and private attributes, so a deep copy keeps the split with callable-object modifiers, and parts_by_data_load returns copies. Tests (MOCK data): the URL cache for hf:// and gs:// in both orders and for a macro sum; before-load direct edits and Reform classes against an after-load reform, against the all-after-load sum; the precedence of +; every modifier sees every parameter change (property over a real Simulation); deep copies with functools.partial; returned parts are copies; the split's modifiers by timing, bracketing, identity and the previous + as reference (properties). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts under the freeze Review r2 (Opus, APPROVE at 0d21113) follow-ups: - A before-load structural reform that changes no parameter (a MOCK re-ordered tenure_type Enum) keeps out of the URL dataset cache, whose enum columns are encoded with the system that built it; a parameter fingerprint alone would not catch it. - The freeze tests also assert the inputs the model calculates, not only the stored dataset. - Docs: the after-load paragraph points to the mixed-sum exception; "change to another value"; the dataset wording before the situation example. Comment on what a deep copy copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming # Conflicts: # docs/book/usage/scenarios.md
MaxGhenis
marked this pull request as ready for review
October 14, 2026 19:20
Collaborator
Author
|
Hand-off to the UK hub.
🤖 Generated with Claude Code |
Collaborator
Author
|
Merged by the merge queue at the handed-off head
|
MaxGhenis
added a commit
that referenced
this pull request
Oct 15, 2026
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.
Fixes #2406
Problem (reproduced on main 714b143, MOCK two-household dataset, fiscal year 2025)
Scenario.__add__droppedapplied_before_data_load.no_economic_assumptions + reformfroze the parameters but not the data.remove_economic_assumptionszeroed the growth in the parameters only afterSimulation.__init__had uprated the dataset's later years with it, anddisable_economic_assumptionswas set too late forbuild_from_multi_year_datasetto reset them.Scenario(parameter_changes={...yoy_growth...}, applied_before_data_load=True)) plus a reform, and even the sum of two before-load scenarios.no_economic_assumptionsno_economic_assumptions + reform, mainno_economic_assumptions + reform, this branchSetting one flag for the whole sum would be wrong. A modifier that edits the data (one that calls
sim.calculate) would then run before the populations exist, astest_flagging_the_whole_sum_would_run_the_data_edit_without_datashows.Fix
Simulation.__init__runs the before-load ones before it builds the data, and the after-load ones at the end, where a scenario's modifier always ran. Data-editing modifiers therefore still see the data.+merged them, which is the precedenceScenario.applygives them, before every modifier and before the data is built. Changing the parameters rebuilds them, so applying an after-load part's changes after a before-load modifier would discard that modifier's direct edits, for example a structural Reform class applied before load (review r1). A deliberate consequence: in a mixed sum, an after-load part's change to a growth input also re-projects the data. Key the URL dataset cache on the uprating inputs, and let parameter changes that reach them re-uprate the data #2405 does the same for any scenario.Scenario.parts_by_data_load()returns(before, after), which is whatSimulationapplies at each time.beforecarries the sum's parameter changes, copied, and the before-load modifiers;aftercarries the after-load modifiers only.parameter_changes,dated_reformsand the chainedsimulation_modifierhold the whole sum, as the previous+made them.Scenario.applyand code that reads the fields see the whole sum. Sums whose parts share one timing take no new path. That covers everyScenario.from_reformtuple and policyengine.py'smodifier + reformsums.applied_before_data_loadis True for a sum when every part that changes something is applied before data load, sono_economic_assumptions + macronow runs before data load. A mixed sum reads False.Scenario()has no timing of its own.__deepcopy__shares one memo between fields and private attributes, somodel_copy(deep=True)keeps the split with callable-object modifiers.hf://andgs://dataset cache. On main,no_economic_assumptionsalone (and so the sum, once fixed) cached its frozen projection under the plain URL, and the baseline and every later simulation in the process picked it up (review r1).Invariants (tested)
test_a_simulation_runs_each_part_once_at_its_own_time(a property over a realSimulation) andtest_a_sum_splits_its_modifiers_by_timing.test_a_before_load_parts_edits_survive_an_after_load_reform). Timing does not change which change holds (test_a_sums_parameter_changes_keep_the_precedence_of_plus).__add__: a sum's fields and chained modifier equal those the previous__add__made, for any bracketing. Uniform-timing sums are unchanged apart from the flag.Scenario()adds no part.no_economic_assumptionsalone:no_economic_assumptions + reform, in either order, has the data (every table, every year) and the economic assumption parameters ofno_economic_assumptionsalone, plus the reform's change. Tested on a single-year dataset, a multi-year dataset (input not mutated), a situation, andhf://andgs://URLs in both cache orders, with the baseline and later simulations kept at current law's projection.MACRO + reform, in either order, projects the data asMACROalone does: 20% growth into 2027, where main gives 2.4%.no_economic_assumptions, in either order.Reproducer: policyengine_uk/tests/test_scenario_sum_timing.py::test_no_economic_assumptions_plus_reform_freezes_the_data
On origin/main (dd64849), 24 of the 35 new tests fail, including the reproducer in both orders and every URL-cache case. The 11 that pass check behaviour main already had: the parameter freeze, a situation, the precedence of
+, before-load direct edits on a situation (main applied every sum after load),Scenario.apply, and that one flag for the whole sum fails.Reviews
parameter_changesisn't associative, is gone.Scenario.__add__.scenario.pyandsimulation.pymerged cleanly. The only conflict was the after-load paragraph inscenarios.md: I kept Read calendar-year series' bare years as calendar years, and make generated-series reforms take effect #2404's text and added this PR's one sentence.Follow-up, filed by the UK hub as #2417: under
no_economic_assumptions,Simulation.datasetholds the projected, un-reset dataset.build_from_single_year_dataset,build_from_datasetandbuild_from_fileoverwrite the reset copy thatbuild_from_multi_year_datasetsets. Sosim.datasetcan disagree with the values the model calculates. That happens on main through the URL cache, and with this PR it also happens forno_economic_assumptionsplus a growth change. The hub is building the fix.Open PRs this touches
Simulation.__init__and_dataset_cache_key. Rebase plan:_projects_data(situation, dataset) and self._scenario_changes_uprating_inputs(...)) toafter_data_load._dataset_cache_key:_scenario_before_data_loadfirst, then Key the URL dataset cache on the uprating inputs, and let parameter changes that reach them re-uprate the data #2405's fingerprint. A before-load structural reform that changes no parameter still poisons the cache through_pre_encode_enum_columnsif only the fingerprint is checked (test_a_before_load_structural_reform_keeps_out_of_the_url_dataset_cache, review r2).__add__'s merge. The parts reuse the sum's merged fields, so it composes; whichever PR lands second keeps those lines.Checks run
ruff format --check .,ruff check ., andruff check --select F811,F821 policyengine_uk: clean.pytest -n 4on the merged head (0d21113): 487 passed, 0 failed. That covered the new file (35 tests) plustest_reform_representations,test_dated_reform_routes,test_no_economic_assumptions,test_uc_rebalancing,test_triple_lock_statutory_inputs,test_capital_gains_projection,test_threshold_indexation,test_domestic_rates_uprating,test_dataframe_dataset,test_abolish_benefit_cap_scenarioandtest_simulation_dataset_sources. On ec25db7 the changed tests pass, and the Enum cache test fails with the flag disabled.axiom: n/a: infra
🤖 Generated with Claude Code