Conversation
Simulation._user_input_keys records each (variable, branch, period) stored through set_input. _invalidate_all_caches (run by apply_reform) keeps the values it names, to_input_dataframe exports them, and country packages read it to tell an entered value from a calculated one. It drifted from storage in two ways (#559): - delete_arrays deleted the values but kept their entries, so a formula result calculated later for the same period counted as an input: it survived apply_reform and was exported. Holder.delete_arrays, which Simulation.delete_arrays calls for each branch it deletes from, now drops the entries for the variable, that branch and the periods in-memory storage deletes (all of them for an eternal variable). Code that deletes through the holder, as country packages do when they move an input to another variable, is covered too. Disk storage deletes only the period asked for, so the entry for a value it still holds is kept. - clone (so also get_branch) shared the record between simulations that store their values separately, so an input set on a clone, on a branch's parent after the branch was made, or on the original after cloning was recorded for both. The copy now gets its own record, and its own empty list of running set_input calls. Tests: example regressions (11 of 12 fail before the fix; the twelfth guards against dropping too much), and a Hypothesis property that runs random set_input / calculate / delete_arrays / clone / get_branch / _invalidate_all_caches sequences against a reference model of each simulation's inputs. Fixes #559 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the review of the first commit: - Holder.delete_arrays no longer looks through the whole record. It compares the periods memory stores for the branch before and after the deletion and discards exactly those entries, so its cost does not grow with the record. Country marginal-rate code deletes every variable on a branch: with 9,000 entries and 3,024 variables the loop took 0.73 s with the scan and takes 0.018 s now (0.004 s without any pruning). A holder with disk storage, which cannot list its periods for every branch name, still looks through the record for the variable's entries in the deleted periods and drops those whose value neither storage holds. - Holder._set records the period as storage keys the value: eternity for an eternal variable whatever period it was set for, and a Period for a handler that passes a string. Each entry names one stored value, so a string-period input is exported and deleting an eternal input drops its entry. - put_in_cache stores with is_input=False (the same keyword as #560), so values a custom set_input handler calculates are formula results, which apply_reform recalculates, not inputs. - subsample starts the record again before it rebuilds the simulation, so it records only what the rebuild stores. Tests: a guard that a memory-only deletion never iterates the record; the eternal, string-period, calculating-handler, disk and subsample cases; the property's model now includes a handler that calculates and stores months under string periods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s as stored Round-2 review fixes: - An entry is dropped only when neither storage still holds its value, so an input deleted from memory that survives on disk stays an input. - Twelve months starting on the first of a month are recorded as the year storage keys them under, so deleting that year drops the entry. - Deleting compares the keys each storage holds before and after: it no longer goes through the record or loads files for a disk-backed holder. - The property model is seeded from the situation, checks to_input_dataframe itself, and a second property covers memory and disk storage. - Regression for the carry-over case found in the review of #562. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
This branch has not been deployed
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 #559.
Problem
Simulation._user_input_keysrecords each(variable, branch, period)a simulation stored throughset_input. In core, two places read it:_invalidate_all_caches(run byapply_reform) keeps the values it names, andto_input_dataframeexports them. In 3.32.12 the record drifted from storage:delete_arraysdeleted values but kept their entries. A formula result calculated later for the same period then counted as an input. It survivedapply_reformand was exported byto_input_dataframe; an input variable read after deletion exported its default. This happened throughSimulation.delete_arraysand throughHolder.delete_arrays, which country packages call directly. policyengine-ussystem.py, for example, movesemployment_incomeintoemployment_income_before_lsrthat way, and on master the record kept('employment_income', 'default', 2025)after the move.auto_carry_over_input_variables, the surviving formula result is also carried into later periods. The review of Carry over only inputs, the latest at or before the requested period #562 ran this case: a variable whose formula gives 7 through 2012 is set to 20 for 2012, deleted, and calculated (7). Afterapply_reform, master gives 7 for 2013, in memory and on disk. A simulation that never had the input gives 0.test_formula_result_for_a_deleted_input_is_not_carried_overpins it for both storages.clone()(so alsoget_branch) shared one record between simulations that store their values separately. As a result:set_inputcalls was shared too.set_inputhandler calculated were recorded as inputs. A handler that callscalculatebefore storing its input made those formula results surviveapply_reform.2025while storage keys it as eternity, so deleting it left its entry."2025-01"was recorded with a string, soto_input_dataframedid not export it.month:2025-01:12,month:2025-03:12) were recorded as twelve months, while storage keys them as the year starting then (2025,year:2025-03). Deleting the value left its entry.subsamplerebuilt every stored value but kept the old record.Change
Holder._setrecords the period storage keys the value under. Storage keys a value by its period's string form, so the recorded period is that string read back: eternity for an eternal variable, the year for twelve months starting on the first of a month, and otherwise the period itself. Each entry therefore names one stored value._settakesis_input: Optional[bool] = None, the same keyword and meaning as Make set_input on a branch drop values calculated from the input it replaces #560:Nonemeans "an input if aset_inputcall is running".put_in_cachepassesFalse, so calculated values are never recorded and never redirected to the input's branch.Holder.delete_arraysnotes the keys memory and disk storage hold before deleting, and afterwards finds the ones that are gone. It drops the entry for a removed key only if neither storage still holds a value for it.Simulation.delete_arrayscalls it for each visible branch.Simulation.clonegives the copyset(self._user_input_keys)and an empty list of runningset_inputcalls.subsample. It starts the record again before it rebuilds the simulation.Unchanged: storage, what
set_inputstores, and the valuesapply_reformkeeps for inputs that were not deleted (test_inputs_not_deleted_are_still_kept_by_apply_reform,test_input_that_was_not_deleted_is_carried_over_after_apply_reform).calculatedoes not read the record. A simulation that never callsapply_reform/_invalidate_all_cachesafter deleting, cloning or running a calculating handler, and never exports, therefore calculates the same values._user_input_keys(git grep). policyengine-us main reads it in one place:Simulation._rebind_holders(policyengine_us/spm.py) replaces a simulation's record with a new set holding the entries for variables its system has. It was written when clones shared one record; with this PR the clone already has its own, and the filter works the same. PE-US's two tests of it pass on this head (see Tests and checks).apply_reform,to_input_dataframe,subsampleordelete_arrays(git grep).system.py) and PE-UK (simulation.py) callapply_reformfor structural reforms before they move inputs such asemployment_incomewithdelete_arrays. Anapply_reformafter such a move and a calculation now recalculates the moved variable; on master it kept the pre-reform formula result.One intended change for handler authors: a value a custom
set_inputhandler stores throughput_in_cacheis a cached calculation, not an input. Handlers store inputs withholder._setorholder.set_input, as core's ownset_input_divide_by_periodandset_input_dispatch_by_perioddo. No handler in policyengine-us, policyengine-uk or policyengine-canada callsput_in_cache(git grep).Performance
delete_arrayson every variable of a branch, as country marginal-rate code does, with a 9,000-entry record (3,000 input variables × 3 years) and 3,024 variables. CPU seconds, median of 5 loops (takeover/record_size_bench.out):test_delete_does_not_look_through_the_whole_recordandtest_disk_delete_reads_no_file_and_does_not_look_through_the_recordmake the record raise on iteration and make disk storage raise on any file read, then delete.Invariants
These hold for every sequence of operations on a family of simulations (a root, its clones and branches):
set_inputstored.set_inputthat some storage still holds, including those it inherited when it was cloned or branched. Values calculated during aset_inputcall are not entries._invalidate_all_cacheskeeps exactly the inputs. Afterwards, the simulation and its branches store their inputs and nothing else.to_input_dataframeexports the recorded periods of the variable's own unit on the branches the simulation reads, with the input's values, and stores nothing.tests/core/test_user_input_keys_property.pyhas two properties:test_user_input_keys_match_reference_modelchecks 1, 2, 3 and 5 after every step, and 4 after every_invalidate_all_caches, on memory storage. It runs 300 random sequences of up to 30 steps.set_input,calculate,Simulation.delete_arrays,Holder.delete_arrays,clone,get_branchand_invalidate_all_caches.set_inputhandler calculatesincome_taxand then stores three months under string periods.to_input_dataframeitself.test_user_input_keys_follow_memory_and_disk_storagechecks 1, 2 and 4 on one simulation whose holders store in memory or on disk, switching between the two at random. It runs 200 sequences of up to 25 steps. Its model takes "some storage still holds the value" from the storages' keys, so it holds however disk storage deletes.set_input('birth', '2025'). On a9bdeff both fail withset_input('salary', 'month:2025-01:12').Mutation check at this head (
takeover/mutants.py): 16 mutants, each killed by at least one of the two modules. They are:_;_storage_period;put_in_cacherecording inputs;subsamplekeeping the record;set_inputcalls.Tests and checks
tests/core/test_user_input_keys.py: 29 example tests. On master 25 fail; the 4 that pass guard against dropping too much. On a9bdeff the 5 for the round-2 findings fail. Both properties fail on master and on a9bdeff.pytest tests: 1,174 passed, 4 skipped, 1 xfailed; country-template YAML: 39 passed (Ubuntu, Python 3.13).takeover/compose_focused.out):Holder._set/put_in_cache, both sides kept);Holder._set/put_in_cache/delete_arraysandSimulation.clone/subsample, with both sides kept; its owner takes the resolution when rebasing.takeover/repro_compose_562.out). On master: 7 in both.make documentationwas not run locally; CI's Test jobs build the documentation, and they pass.Review
subsample); three are disk-storage bugs listed below.test_entry_is_kept_while_disk_still_holds_a_value_deleted_from_memory;test_twelve_month_input_is_recorded_as_the_year_it_is_stored_as;to_input_dataframe: fixed;put_in_cacheis no longer recorded: intended, see Change;calculate_addover a period that holds an input (a monthly variable given a two-month input) overwrites the input with the sum, on master and here: storage behaviour, not the record. Carry over only inputs, the latest at or before the requested period #562'sput_in_cacheguard keeps the input.Not in this PR
Pre-existing disk-storage bugs that change storage itself, not the record:
OnDiskStorage.clonekeeps the same.npypaths, so a clone writing a key overwrites the original's file (Give each disk-backed holder storage its own directory #558 gives each holder storage its own directory)._, andget_known_branch_periodssplits on every_. Read only periods the current branch can see when uprating or carrying over #552 parses the key on its last_, which fixes both.OnDiskStorage.delete(period)deletes only that exact period (OnDiskStorage.delete(period) deletes only that exact period, not the periods within it #564, fixed in Delete the periods within a deleted period from disk storage #565).InMemoryStorage.deletedeletes the periods within it, asHolder.delete_arraysdocuments.The disk tests here compare the record with what storage still holds, so they pass before and after #552 and #565.
#560 moves
_invalidate_all_cachesonto per-array input flags.to_input_dataframestill reads_user_input_keys, so this fix is needed either way._set'sis_inputkeyword is shared, and the two compose. #562 adds aderivedkeyword next to it; the resolution keeps both (takeover/compose_562_resolution.diff).axiom: n/a: infrastructure (simulation engine), no policy rule.
🤖 Generated with Claude Code