Conversation
copy and pickle rebuild an object by creating an empty instance and probing it for __setstate__ before restoring its __dict__. Population.__getattr__ answered that probe through the projector lookup, which reads self.entity; on the empty instance that read re-entered __getattr__ until RecursionError. So copy.deepcopy(simulation), pickle.loads(pickle.dumps(simulation)) and copy.copy(population) failed on every simulation, on Python 3.11 to 3.14. The same path had three more defects: - VectorialParameterNodeAtInstant and TracingParameterNodeAtInstant recursed the same way through the attribute they forward to. - The vectorial node forwarded __deepcopy__ to its numpy vector, so a deep copy came back as a bare recarray. These nodes are cached on the parameter nodes a tax-benefit system keeps. - numpy's __reduce__ rebuilt an EnumArray without possible_values, so an unpickled enum array could be neither decoded nor compared with an enum item. EnumArray now pickles its enum by name and restores it when the process can find it, otherwise None. Tests: examples for each defect, plus a Hypothesis property (in its own importorskip module, for the smoke job) that a deep copy or pickle round trip calculates what a freshly built simulation does and that writes to the copy never reach the original. 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.
Summary
On master (3.32.12, b78b0ba) no simulation can be copied or unpickled:
Found by the adversarial review of #561. Same result on Python 3.11, 3.12, 3.13 and 3.14.
Cause
copyandpicklerebuild an object by creating an empty instance and probing it for__setstate__before restoring its__dict__.Population.__getattr__answers every missing attribute throughget_projector_from_shortcut, which readspopulation.entity. On the empty instanceentityis missing too, so that read calls__getattr__("entity"), which readspopulation.entityagain, until the interpreter raisesRecursionError.hasattronly swallowsAttributeError, so the error escapes.Fixing that exposed three more defects on the same path, all fixed here:
Population.__getattr__VectorialParameterNodeAtInstant.__getattr__self.vectorwhen unpickled. It also forwards__deepcopy__to the vector, andcopy.deepcopylooks that method up on the instance, so a deep copy came back as a barenumpy.recarray. These nodes are cached on the parameter nodes a tax-benefit system keeps, so a copied simulation would carry the wrong type.TracingParameterNodeAtInstant.__getattr__self.parameter_node_at_instant.EnumArray__reduce__rebuilds the array withoutpossible_values, so an unpickled array could not be decoded or compared with an enum item (AttributeError). Formulas that readarray.possible_valuesfailed on an unpickled simulation.Reform.__getattr__has the same shape but terminates:TaxBenefitSystemdefinesbaseline = Noneon the class.Dataset.__getattr__was already guarded.Changes
Population,VectorialParameterNodeAtInstant,TracingParameterNodeAtInstant:__getattr__raisesAttributeErrorfor the instance's own attribute(s) it reads, so a lookup on an unfilled instance ends. The vectorial node also stops forwarding__deepcopy__. No other lookup changes: projector shortcuts and vector attributes resolve as before.EnumArray.__reduce__carries the enum by module and qualified name. Unpickling resolves it when this process can find it, and otherwise returns the array withpossible_values = None. Pickling the enum by reference instead would have made arrays that cross a process boundary today fail to unpickle at all (see limits). Pickles written by earlier releases still load.Invariants
Stated and tested (
tests/core/test_simulation_copy_pickle.py,tests/core/test_simulation_copy_pickle_property.py):copy.deepcopy(s)andpickle.loads(pickle.dumps(s))calculate what a freshly built simulation calculates, for 8 variables and for the branch.Population,GroupPopulation, vectorial node or tracing node raisesAttributeError.EnumArrayis anEnumArraywith the same values and dtype.possible_valuesafter unpickling is the same enum when the process can find it andNoneotherwise; unpickling never raises because of the enum.The Hypothesis property (1, 2) ran 2,000 examples locally. It is in its own module behind
pytest.importorskip("hypothesis"), because the country-package smoke job installs no dev extras.Each guard was checked by mutation: removing any one of them fails between 1 and 17 of the new tests.
Limits (not changed here)
add_variables_from_fileregisters each variable file underf"{id(self)}_{hash(path)}_{file_name}", a name no other process has, so a simulation (or tax-benefit system, or variable) unpickled elsewhere raisesModuleNotFoundError. Changing that naming is not small: Simulations pickled in one process cannot be unpickled in another #568.spm_forecast_providerholdsspm_calculator'sSPMForecast, whoseMappingProxyTypefields cannot be copied or pickled: SPMForecast cannot be pickled or deep-copied (mappingproxy fields) spm-calculator#49. With the tax-benefit systems shared between original and copy, a policyengine-us (2.21.0) household simulation deep-copies and round-trips through pickle in the same process with this branch, and recalculates the same values.Downstream use
git grepon the default branches of policyengine.py, policyengine-api, policyengine-api-v2, policyengine-household-api, policyengine-us, policyengine-uk, policyengine-canada, policyengine-us-data and policyengine-uk-data: nothing pickles or copies a simulation. Theirdeepcopycalls are on JSON-like dicts, and the us-data worker pools pass file paths and arrays and build oneMicrosimulationper worker. policyengine-us clones systems through its ownclone_spm_system. #560 picklesInMemoryStorageandStoreHistoryon their own; it touches none of these files.Tests
uv run pytest testson Python 3.14: 1183 passed, 4 skipped, 1 xfailedpolicyengine-core test policyengine_core/country_template/tests -c policyengine_core.country_template: 39 passedruff format --check .,ruff check .: cleanRUN_SMOKE_TESTS=0 python -m pytest -m smoke --collect-only): exit 0, property module skippedNot run:
make documentation, mypy.axiom: n/a: engine infrastructure, no policy rule.
🤖 Generated with Claude Code