Repository navigation
Let simulations be deep-copied and unpickled - #567
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>
|
#574 and #584 have merged and now cover two of this PR's hunks, so this branch conflicts with master in two files:
Resolution: take master's version of both files. Master's guard covers what this PR's two hunks did (the unfilled-instance recursion and the delegated Checked locally: I merged master (042adb5) into this branch at b6c68eb and took master's side of those two files, with no other conflicts. This PR's The |
Keep master parameter and tracing implementations while retaining Population and EnumArray repairs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guard Population protocol probes before projector lookup. Add regressions for protocols 0-5, property-backed populations, slotted tracing nodes, vector fields, and NumPy differential copy behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
On the original base (3.32.12, b78b0ba), the original author reported that simulations could not 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.__getattr__rejects its missing backing attributes and copy/pickle protocol probes before projector lookup. This also handles subclasses with a property-backedentityduring reconstruction; ordinary projector shortcuts still resolve.__slots__.EnumArray.__reduce__carries the concrete array class and the enum's module and qualified name. Reconstruction preserves the subclass without invoking its constructor. If enum lookup raisesImportErrororAttributeError,possible_valuesisNone; other import/initialization errors propagate.possible_valuesstill load and can now be re-pickled with that metadata set toNone. Reconstruction also accepts the earlier two-argument reducer payload.Invariants
The existing simulation differential/property coverage is preserved; these checks are defined in
tests/core/test_simulation_copy_pickle.pyandtests/core/test_simulation_copy_pickle_property.py:AttributeError, including property-backed population subclasses during reconstruction.NoneonImportError/AttributeError; legacy arrays without metadata remain serializable.The new regression cases cover EnumArray subclasses and legacy re-pickling under protocols 0–5, shallow/deep copying, property-backed populations, slotted scalar/vectorial tracing nodes under protocols 0–5, and a legitimate parameter field named
vector. This sweep could not execute them because the required test wrapper is blocked by the sandbox.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.Validation
Current repair head: f310a8e.
git diff --checkpassed.ps, soheavy_run.shrefused its nice-level check. Zero tests executed; the wrapper was not bypassed.~/reviews/us-hub/scripts/heavy_run.sh core567-final uv run --no-sync pytest tests/core/test_simulation_copy_pickle.py tests/core/test_simulation_copy_pickle_property.py tests/core/parameters/test_vectorial_parameter_node_copy.py tests/core/parameters/test_vectorial_parameter_node_copy_property.py tests/core/test_tracing_parameter_isolation.py tests/core/test_tracing_parameter_isolation_properties.py tests/core/test_tracers.py tests/core/enums/test_enum.py tests/core/test_projectors.py tests/core/parameters_fancy_indexing/test_fancy_indexing.py -q -p no:cacheprovider -n 2.Downstream impact
Re-run pending at f310a8e. The hub's existing downstream A/B gate remains outstanding. This sweep ran no microsimulation and changed no partner baseline expectations. #570 must land after #567 and retain protocols 0–5 support.
axiom: n/a: engine infrastructure, no policy rule.
🤖 Generated with Claude Code