Repository navigation
Export the identifiers for every year an input frame names, so it round-trips - #2349
Merged
Merged
Conversation
Simulation.to_input_dataframe() now gives the person, benefit unit and household identifiers for each whole year the frame gives other values for. The dataset a frame loads as holds them in each of those years, so a simulation built from the frame records them there and exported them, while the simulation that made the frame held them in a year after its data only by carrying them forward. Since #2251 every simulation records uc_receives_new_claimant_health_element for 2026 to 2029, so every frame export failed the round trip that #2270's property test checks: 18 columns exported, 38 re-exported. Adds the minimal counterexample as an explicit example, regression tests for the rebalancing's years and for inputs set by hand before, among and after them, a unit test of the helper, and a property that a frame over several years, given inputs for other years once built, exports the same frame again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loader sets all five identifiers for each whole year an identifier column names, as well as for each year of the dataset, so an export that gives one identifier for a year alone (person_id set by hand for 2032) still re-exported four more columns. The helper now adds every identifier the frame gives for each whole year a model-variable column names, identifier columns included. Also corrects the account of a year before the data, where inputs do not carry back and the exporting simulation's identifiers are zero, and states that the ordering matches the default (sorted) export. Adds an identifier-only case to the regression and the property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
From the review of #2349 (the export helper this builds on): - Limit the claims that the columns sort by period, and that the frame loads back to the same frame, to the default export. With include_computed_variables=True, core exports each variable's periods in the order they are stored. - Say that with_identifiers_in_each_year returns the frame itself when it adds nothing, and otherwise a new frame. - Test that the loader and the export find the same years for frames that name identifiers, values and months for different years. The round trip relies on their agreeing, and each works the years out separately. - Test that an export whose person_id differs between years, before or after the data, is still refused when loaded. #2076 also overrides Simulation.set_input, just after delete_arrays. Put this override in the same place, so that if both PRs land git reports a conflict to resolve, rather than one override silently replacing the other in the class body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
UK hub landing audit for #2349, head
|
This was referenced Oct 11, 2026
Relieve pension contributions to relevant earnings and charge the annual allowance excess once
#2240
Open
This was referenced Oct 11, 2026
This was referenced Oct 11, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 12, 2026
…f-claimant-partner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 #2333
Fixes the main CI failure from #2251 × #2270.
What and why
Main has been red since #2251 merged (cfa0c55). One test fails:
test_dataframe_dataset.py::test_an_exported_frame_loads_back_and_exports_itself(main run 38143054188 at 7343f94: 1 failed, 3749 passed, 1 skipped, 2 xfailed, 1 xpassed). #2270 added that property: a frameto_input_dataframe()exports should load back to a simulation that exports the same frame.Mechanism
Simulation.__init__applies the Universal Credit rebalancing modifier to every simulation (policyengine_uk/simulation.py:198)._new_claimant_health_element_statusrecordsuc_receives_new_claimant_health_elementwithsim.set_inputwhen nothing supplies it (policyengine_uk/scenarios/uc_reform.py:114-121, called from:140-145)._user_input_keysrecords (core 3.32.27simulations/simulation.py:2208-2234,to_input_dataframeat:2236-2268).:1198-1240;auto_carry_over_input_variables,policyengine_uk/tax_benefit_system.py:75).person_id,benunit_id,household_id,person_benunit_idandperson_household_idfor 2025 alone.dataset_from_input_dataframemakes a dataset year for each whole calendar year a non-identifier column names (policyengine_uk/data/input_dataframe.py:368-372,:401).:427-439).:418-426).build_from_multi_year_datasetthen callsset_inputfor every column of every year (policyengine_uk/simulation.py:544-549).The same gap was there before #2251 for any input set after building for a year outside the data, for example
sim.set_input("hours_worked", 2027, ...). #2251 made every simulation do that.Bisect
The hub ran
repro_roundtrip.py(the minimal example: one person aged 0, benunit 1, household 1, LONDON, every other input 0 or false,id_columns="both"):On this branch before the fix (6d89338), I reproduced 18 → 38. The extra columns are the five identifiers for each of 2026 to 2029.
Fix: the exporter
The export now gives every identifier for each whole calendar year one of its columns names. Those are exactly the years in which a simulation built from the frame holds them as inputs.
with_identifiers_in_each_year(policyengine_uk/data/input_dataframe.py:443) adds a column for each identifier the export gives, for each such year it lacks._identifier).assert_frame_equalchecks column order._is_whole_year(:276), extracted from the loader without changing it.Simulation.to_input_dataframe(policyengine_uk/simulation.py:432-453) calls core's export, then the helper.Why the exporter, not the loader. The loader treats identifiers as holding in every period (
input_dataframe.py:368-369) and holds them in every dataset year by design. Invariant 3 asserts that every dataset year holds the identifiers. With this fix, the loader and every simulation it builds are unchanged; the export now states the identifiers the loader would have set anyway.A loader-side fix would make the loader forget identifier input records for years the frame does not name and rely on carry-forward. It needs special cases:
Why not #2251's recording. #2251 records a seeded draw for data-built simulations, and a frame-built simulation is one, so not recording the variable's default would not help here. Any other later-year
set_inputbreaks the round trip the same way.The other
set_inputcalls underpolicyengine_uk/scenariossetpip(pip_reform.py:18, which hasadds) anduc_LCWRA_element(uc_reform.py:159, which has a formula). The default export leaves formula variables out, so neither reaches it; the fix covers them anyway.File ownership. The
simulation.pydiff is the import and the newto_input_dataframemethod besidebuild_from_dataframe, nothing else.Invariants
Sbuilt from a frame,export(load(export(S))) == export(S), column order and dtypes included. This holds when the frame names identifiers and values for different years, and whenSwas given inputs (or a lone identifier) for years outside its data once built.person_idand the benefit unit and household identifiers exactly for every whole year any of its columns names, and every identifier it gives for some year, for all of those years.with_identifiers_in_each_yearis idempotent, and does not change its argument.Tests
New, in
policyengine_uk/tests/test_dataframe_dataset.py:@exampleontest_an_exported_frame_loads_back_and_exports_itself.test_an_export_names_the_people_in_each_year_the_rebalancing_records: the Stop deciding a household's UC health element rate by its position #2251 case, deterministic.test_an_input_set_once_built_for_another_year_round_trips: an input set by hand for 2024, 2027 or 2032, and a loneperson_idfor 2032.test_an_export_gives_its_identifiers_for_each_year_it_gives_values_for: a unit test of the helper. It covers order, sources and dtypes, months, years only an identifier names, non-variables, idempotence, and that the argument is not changed.test_a_frame_over_several_years_exports_the_same_frame_again, with two explicit examples. Each frame names identifiers for 1–2 years and gives values for 1–3 years, both drawn from 2024–2030. The simulation is givenhours_workedandperson_idfor other years once built. The property asserts that the second export equals the first and that the identifiers are exact in every year.The invariant docstring (item 5) is extended. No test was weakened, skipped or marked xfail.
Results
All runs used policyengine-core 3.32.27 (the
uv.lockpin), Python 3.13 and Hypothesis 6.168.2, on an overloaded host (load average 200–470).The minimal example, through the hub's
repro_roundtrip.py:pytest policyengine_uk/tests/test_dataframe_dataset.py policyengine_uk/tests/test_uc_rebalancing.py policyengine_uk/tests/test_input_records.py -n 3 --dist workstealon 49e7ef3: 96 passed, 1 failed (1:54:39). The later head a966445 only reflows a docstring.test_dataframe_dataset.py, including every new testtest_uc_rebalancing.pytest_input_records.pyThe one failure is
test_uc_rebalancing.py::test_section_4_floor_properties. It raisedhypothesis.errors.FailedHealthCheck: Input generation is slow: Hypothesis only generated 2 valid inputs after 656.73 seconds, which is host starvation, not a property violation. That test checks pure arithmetic inuc_reform.protected_lcwra_floor, which this PR does not touch, and it passed on main CI. Run alone with the same seed (pytest policyengine_uk/tests/test_uc_rebalancing.py::test_section_4_floor_properties --hypothesis-seed=324857880990766857290461818381978056954), it passes: 1 passed.Mutation check. I removed the UK
to_input_dataframeoverride in-process, so core's export ran as on main, and ran the new regressions and the@example: all 6 fail (6 failed, 72 deselected).@examplefails at the round trip'sassert_frame_equal.Independent review. An Opus 5.5 agent reviewed the diff, reading code only, and agreed with the exporter-side fix. I fixed its two minor findings in 49e7ef3:
Not run locally: the full
make testsuite (CI runs it) andmake documentation; the docs change is one Markdown bullet.Impact
No calculated value or aggregate changes: the fix touches only the DataFrame export and a no-op refactor of the loader.
MicrosimulationsubclassesSimulation(policyengine_uk/microsimulation.py:14) and has no constructor or export of its own.Simulation.__init__sends only apd.DataFrametobuild_from_dataframe(simulation.py:180-181), the only caller ofdataset_from_input_dataframe(:415). An h5 path or URL goes throughbuild_from_dataset_source→build_from_url/build_from_file→build_from_multi_year_dataset(:178,:309-398,:519-551), as do dataset objects and the default dataset (:182-189).policyengine_ukoutside tests callsto_input_dataframe. In core 3.32.27 its only caller isSimulation.subsample(coresimulations/simulation.py:2330).Follow-ups (not in this PR)
:1220,:1241-1250). The frame it exports loads back with the real identifiers there, as it did before this PR.is_benunit_eldest_child.py:22,months_since_last_birthday.py:36anduc_deduction_random_draw.py:25.subsample, from reading, not run. It rebuilds withself.build_from_dataset()and no argument (core:2410), but UK'sbuild_from_dataset(self, dataset)(simulation.py:455) requires one.to_input_dict()is unchanged and still gives the identifiers only for the periods they were set for.Test plan
ruff formatandruff checkon the changed files (ruff 0.15.7): clean.changelog.d/fix-roundtrip-recorded-inputs.fixed.md.axiom: n/a: DataFrame export round trip only; no policy, parameter or formula change
🤖 Generated with Claude Code