Repository navigation
Load a DataFrame simulation input as the dataset it describes - #2270
Merged
Merged
Conversation
Simulation(dataset=<DataFrame>) set every column as one value per person, so a benefit unit or household variable had the wrong length whenever a group had more than one person, and single-person groups took each other's values when rows were not in identifier order. It also found identifier columns by substring, so household_id matched person_household_id. build_from_dataframe now turns the frame into the UKMultiYearDataset it describes (policyengine_uk/data/input_dataframe.py) and loads that. Each benefit unit and household takes the value on its members' rows, which must agree. Groups come from the exact benunit_id and household_id columns, falling back to the person_ columns, then to one group per person. Columns are matched by exact variable name. Simulation.dataset now holds the multi-year dataset, so formulas that read it (State Pension data year, consumption and wealth shares, budget incidence) behave as they do for a dataset-built simulation. The person_ identifier variables are 32-bit floats (#2267), so the loader compares them with the group's own identifier at that precision, and refuses a frame that names groups or people only with 32-bit float identifiers of 2**24 or more, a repeated person, or contradictory identifier columns, instead of merging households. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mutant that took a January column as the whole year survived the month test, since core carries January into later months either way. The dataset holds whole years only; assert it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eciles Review round 1 (subfleet uk2266-review-r1) found three problems: - An object column mixing np.float32(2**24) and 2**24 + 1 merged two people or groups: the guard looked only at the column's dtype, and numpy compares the two as equal. Every identifier the loader uses is now read as an exact int64, value by value for object columns. Values that are not whole numbers, and floats at or above their type's exact limit, are refused. A person_<group>_id column beside <group>_id is only compared with it, at each value's own precision. - A variable given for months and for the year was set years first, so a year's value spread over different months than when each column is set in turn. Such a variable's columns are now set again in the frame's order after the dataset loads, as the previous loader did. - With Simulation.dataset set, the NHS, education and other public spending budget allocations raised KeyError when the data left an income decile empty (a small sample does; so did any small dataset-built simulation already). An empty decile's share now goes to nobody. Tests: example refusals for mixed and non-whole identifiers; a property writing identifiers as Python and NumPy integers and floats; both orders of a monthly and yearly input; a differential property against the previous loader on the frames it read correctly; and spending reforms on a seven-household frame and its dataset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the seven-household fixture, corporate wealth carries a whole population's corporate tax, so six households had negative income, fell in decile -1 and correctly got nothing, which the test took for a failure. Use the fixture without corporate wealth, and assert that a household gets a share exactly when its decile is 1 to 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 (subfleet uk2266-review-r2-opus, by reading) found: - An exported frame saved as CSV was refused. The exported person_<group>_id columns hold 32-bit floats; CSV writes each with the fewest digits that give it back at 32 bits (900,000,064 as 900000060.0), so it reads back as a different 64-bit float, which an exact comparison with <group>_id rejected. A float membership value now names a group if it is the same 32-bit float as its identifier. - An identifier column named for an earlier year than the data added a year with no data, which became the data year the State Pension formulas read, so the pension was zero. Identifiers hold for every period: the dataset's years are now those the frame gives values for, falling back to the identifier columns' years, then the default. - A Python int beyond 64 bits raised OverflowError, not ValueError. - Groups named only by float person_ columns of 2**24 or more may have been rounded before the frame was made, which no check can see; the loader now warns and asks for the group's own identifier column. Tests: the CSV round trip as an example and inside the export property (which found the 900000060.0 case), the data year against the previous loader, the 64-bit bound, and the warning. Docs and changelog quote 2**24 so that Markdown does not read it as bold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ment Review round 3 (subfleet uk2266-review-r3-opus) approved 6d68934 with nits, and a mutant survived. Addressed here: - An identifier column named for a year with no data no longer adds a dataset year, so its identifiers were not stored for that year and read as zero before the data (inputs carry forward, not back). They are now set for each such year, as the previous loader set them. - A float person_<group>_id value agreed with any group identifier that rounds to the same 32-bit float, so two exact 64-bit floats naming each other's households passed. It now agrees only if it is the identifier exactly, or that 32-bit float as held or as CSV writes it. - No test gave a mixed object person_ column a float that names the wrong group (the surviving mutant). One does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two mutants survived the mutation run at 58af8c5: accepting a fractional float inside an object identifier column, and dividing a decile's spending share by a total weight of zero. Both are refused or skipped by the code; neither had a test. Add them. Rewrap one docstring paragraph (review round 4, nit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
marked this pull request as ready for review
October 11, 2026 01:56
CI resolves pandas 3.0.6 and policyengine-core 3.33.2. From core 3.33, to_input_dataframe() exports an enum input as its stored EnumArray, which pandas 3 keeps in the column until a copy turns it into a plain array. assert_frame_equal compares array classes, so the check that the loader leaves the exported frame unchanged failed on the class, not the values (the whole-model differential just before it passed). Compare a copy, which still shows any change to the values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
Hand-off to the UK hub (session
|
Collaborator
Author
|
UK hub: landed on the gates at head
|
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
Main push runs were cancelled when a newer commit landed, so 83001d5 and cfa0c55 never finished a Test on 11 October, and a conflict between #2251 and #2270, each green alone, showed only at 7343f94. Remove the main push workflow's concurrency group so every merged commit finishes its run. pr_code_changes.yaml also runs on merge_group, so a merge queue on main can require the same checks and test each pull request merged onto the latest main before it lands; the Changes job compares a group with the main it merges onto. Main then moves only to a commit the queue tested, so the main push workflow's new Tested job skips the suite for a commit that already has a passing "Test / Result" check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
Brings in #2258 (120-minute Test job), #2180 (NICs thresholds indexed by September CPI after 2030-31), #2063 (salary sacrifice needs pay), #2038 (winter payments to the member a benefit is paid to), #2259, #2248, #2251, #2262, #2266, #2270, #2285, #2288, #2294 and #2021. Conflict: test_legacy_award_readers_properties.py imports. Kept both hypothesis.event (this branch's screened-out events) and hypothesis.example (#2294's found families). The rest of the file merged cleanly: #2294's without/silent/with-other comparison, the severe disability premium bound and the held benefit cap sit alongside this branch's work generators, screened reference and outside-member tests. Two comments now say the ESA and JSA readers also read the members' work. _legacy_award_payee.py merged cleanly: #2038's can_claim (Pension Credit) and this branch's own_report_is_paid are independent. The shared NI parameters already match main (#2058); #2180 changes only 2031-32 onward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
This was referenced Oct 11, 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 #2266
axiom: n/a: simulation infrastructure, no policy rule change
What was wrong
Simulation.build_from_dataframeset every column of a frame (one row per person, columnsvariable__period, asto_input_dataframe()exports) as one value per person. So:ValueError: Unable to set value ... as its length is 2 while there are 1 benunits(the issue's reproduction, confirmed ona3752304);household_id__matchedperson_household_id__2025;docs/book/usage/simulations.md) raisedIndexError.What changed
policyengine_uk/data/input_dataframe.py(new):dataset_from_input_dataframeturns the frame into theUKMultiYearDatasetit describes. It has a year for each whole calendar-year period the columns name. Each benefit unit and household takes the value on its members' rows, and those rows must agree. Periods that are not one whole year, such as months, are returned to be set directly.Simulation.build_from_dataframeloads that dataset withbuild_from_multi_year_dataset, the same path as a multi-year dataset, then sets the other periods.column.split("__")[0] == variable(viastr.partition).objectcolumns) are read value by value.nhs_budget_change,education_budget_changeandother_public_spending_budget_changeskip income deciles with no weighted households. Before, they raisedKeyErrorfor any dataset-built simulation whose data left a decile empty, such as a small sample (confirmed for a seven-household dataset). Settingdataseton frame-built simulations sends them down this path. A full dataset fills every decile, so its results do not change.docs/book/usage/simulations.mdnow shows a multi-person household and says how groups are named.Decisions
Simulation.datasetis now the multi-year dataset, notNone. Formulas branch onsimulation.dataset is not Noneor readdataset.years. These includebasic_state_pension/new_state_pension/additional_state_pension(data year),consumption_shareholding,shareholding, andnhs_budget_changeand the other budget-incidence variables. I took a dataset-built simulation of seven households, exported it withto_input_dataframe(include_computed_variables=True)and reloaded it:datasetset, every one of the model's 1,117 variables matched in 2025 and 2027;datasetforced toNone, 72 variables differed.The test
test_an_exported_frame_gives_the_results_of_its_datasetnow checks the first result. A frame-built simulation also behaves like a dataset-built one underno_economic_assumptions, which resets uprating ondataset.Identifiers that 32-bit floats round (#2267, not fixed here).
person_household_idandperson_benunit_idare stored as float32, so an exported frame holds them rounded above2**24. The loader:household_id/benunit_idcolumns first, then theperson_columns, then one group per person;person_<group>_idwith<group>_idexactly when theperson_value is an integer (ready for person_household_id and person_benunit_id are stored as float32, which rounds large identifiers #2267 making them integers). A float value agrees if it is the group's identifier exactly, or the identifier rounded to a 32-bit float, either as held or as CSV writes it. That covers the exported float32 column, and the same frame saved as CSV: CSV writes the float32900000064as900000060.0, a different 64-bit float. Any other float that merely rounds to the same 32-bit float names another group and is refused, so two exact 64-bit floats naming each other's households do not pass;person_columns of2**24or more. Those may have been rounded before the frame was made, for example an export saved as CSV with the<group>_idcolumns dropped, and no check can see that;2**24or more.2**24 + 1rounds to2**24, so the bound is>=. The same rule applies to any float, at its own limit (2**53for 64-bit floats), and to each value of a mixedobjectcolumn. In such a column,np.float32(2**24)compares equal to2**24 + 1, a case review round 1 found.It also refuses:
<group>_idandperson_<group>_idcolumns that name different groups;variable__period(these crashed before too).Months and years. Core spreads a year's value over the months not yet set. It divides what is left for floats, and fills the rest for other types. So when a frame gives one variable for months and for the year, the order of its columns decides the months' values, as it did when the previous loader set each column in turn. The loader keeps that order. A frame
to_input_dataframe()exports never gives one variable for both: it exports each variable only for periods of its own definition period.Years. The dataset's years are the years the frame gives values for. Identifier columns add none, because identifiers hold for every period. This matters because the State Pension formulas read the dataset's first year as the data year. An identifier column named for 2025 beside data for 2027 used to add an empty 2025 and make the pension zero. Review round 2 found that; it is now tested against the previous loader. A frame with values in several years has its first such year as the data year, as a multi-year dataset does.
An identifier column named for a year with no data still has its identifiers set for that year, as the previous loader set them. Inputs carry forward but not back, so they would otherwise read as zero before the data.
A frame is loaded with exactly the years it gives values for. A one-year frame is not projected forward the way a
UKSingleYearDatasetis: that would change later-year results for frames that load today. A frame that names no whole year is held indefault_input_period.Not checked (as for the dataset loaders): a benefit unit whose members are in different households.
Not changed:
shareholding,consumption_shareholdingandconsumer_incident_tax_revenue_changedivide by a weighted total. That total can be zero while the unweighted total is not, for example when every household holding wealth has zero weight. Dataset-built simulations already behave this way, and frame-built ones now share it. Review round 2 raised it as a nit. It needs its own change to those formulas: #2296.Invariants (property-tested with Hypothesis in
policyengine_uk/tests/test_dataframe_dataset.py)For every frame of people in benefit units in households, in any row order, whichever identifier columns name the groups (
<group>_id,person_<group>_id, both, or as exported with float32person_columns), with identifiers near 1, near2**24and near 9e8:People and groups. People are in row order. Benefit units and households are the distinct identifiers in ascending order, and each person is in the ones their row names, also for identifiers float32 cannot tell apart.
Values. Every person, benefit unit and household variable has the value its rows give. Projected back to people, each variable gives its column again.
Dataset.
datasetholds the same people, groups and values with exact identifiers.Row order. Reordering rows changes no entity's values.
Round trip.
to_input_dataframe()loads back to the same people, groups and inputs, and exports the same frame again. So does that frame saved as CSV.Differential. A simulation built from a dataset and one built from its exported frame agree on every variable of the model. Checked with the default export and with
include_computed_variables=True, and for a reform and its baseline.Exact names. Columns whose names only contain a variable's name change nothing.
No mutation. The frame is not changed.
Previous loader (differential). On every frame the previous loader read correctly (single-person groups, rows in identifier order), both loaders give every input the same value in every period they set. This includes monthly inputs given for some months and the year, in any column order.
Mixed identifier types. With identifiers written as Python or NumPy integers or floats in
objectcolumns, a frame loads its people and groups exactly, or is refused when an identifier the groups are read from is a float32 of2**24or more.Small-sample spending. For the three budget reforms on a seven-household frame and on its dataset, allocations are equal, finite and positive. Households in the same decile get the same amount, and the total is under the budget.
Intended exceptions are the refusals above. Each has an example test, including the minimal counterexamples found while writing this (
2**24 + 1vs2**24, and repeated person ids), which merged households before those checks were added.A limit of the default export, not of the loader:
to_input_dataframe()leaves out inputs a dataset supplies for variables with formulas, such asstate_pension_reported.include_computed_variables=Truekeeps them. Filed as #2297.Interaction with #2265
#2265 (open) adds
Simulation.subsample. I merged both branches locally and rantest_subsample.py: every test passed but one. The failure istest_a_simulation_built_from_a_dataframe_has_no_dataset_to_subsample, which asserts that a frame-built simulation hasdataset is None. That stops holding here, by design. With this change, subsampling a frame-built simulation also cuts its dataset down to the sampled households, which is the behaviour #2265 already has for dataset-built simulations.Whichever of the two PRs lands second needs that test replaced. The replacement,
test_a_simulation_built_from_a_dataframe_subsamples_its_dataset, checks:UKMultiYearDataset;It passes on the merge. The patch against #2265's test file:
Patch for policyengine_uk/tests/test_subsample.py
#2265's last
Testrun was cancelled, so it has no green run that predates this change.Review
Round 1 (GPT-6.1 Sol, at
620c2483): REQUEST_CHANGES with three findings, all addressed in8b2baaa1:KeyErroron small frames.Round 2 (Opus 5.5, at
cc126d88, by reading only: its lane had no shell): REQUEST_CHANGES. It confirmed the three round-1 fixes and raised two should-fix findings and four nits. I executed the findings and confirmed A, B and D. Addressed in6d689341:OverflowError.person_columns given alone cannot be detected, so the loader now warns.2**24rendered as bold.Round 3 (Opus 5.5, at
6d689341, by reading only): APPROVE with three nits. Addressed in58af8c52:Round 4 (Opus 5.5, at
58af8c52, by reading only): APPROVE, no blocker or should-fix. It checked that pandas 2.3.1to_csvwrites a float32 column asstr(np.float32(v)), which is the form the loader accepts. Two nits are left as they are:person_value is compared exactly only below2**53(the integer column still decides the group);Round 5 (delta review of
4892db98, which adds two tests and rewraps a docstring): APPROVE at4892db98, the current head (Opus 5.5, by reading). I confirmed withgit diff --stat 58af8c52 4892db98that the commit touches only the test file and that docstring.CI on
58af8c52failed one test of 3,697.test_an_exported_frame_gives_the_results_of_its_dataset[default export]failed at its check that the loader leaves the exported frame unchanged: the column's array class differed (EnumArrayagainstndarray), not its values. From policyengine-core 3.33, which CI resolves,to_input_dataframe()exports an enum input as its storedEnumArray. pandas 3 keeps that class until a copy, andassert_frame_equalcompares classes. The whole-model differential on the line before passed.3b8f54b2compares a copy. Round 6 (delta review of3b8f54b2): APPROVE at3b8f54b2, the current head (Opus 5.5, by reading). It traced the class change to pandas 3's deep copy merging same-dtype blocks withnp.vstack. It confirmed that comparing a copy still catches any change to the frame's values, and that the loader reads enum columns as plain integer codes.Rounds 2 to 6 ran on Opus 5.5 lanes without a shell: the GPT-6.1 Sol lanes that ran round 1 stayed queued. So the reviews are by reading, and the execution evidence is the local test and mutation runs below, plus CI.
Tests run
Locally, Python 3.13, policyengine-core 3.32.27, one process each,
-p no:cacheprovider, fresh--basetempdeleted afterwards, at4892db98(the last code change;3b8f54b2changes one test assertion) unless noted:policyengine_uk/tests/test_dataframe_dataset.py: 71 passed in 160 s, peak RSS 0.7 GB, with the locked versions (pandas 2.3.1, policyengine-core 3.32.27).The same file with the versions CI resolves (pandas 3.0.6, numpy 2.5.4, policyengine-core 3.33.2), at
3b8f54b2: 71 passed in 158 s, peak RSS 0.8 GB.test_input_records.py,test_simulation_dataset_sources.py,test_dataset_sources.py,test_no_economic_assumptions.py,test_household_income_decile.py: 22 passed (at58af8c52; only tests and a docstring changed after).Mutation check: 36 deliberately broken versions, each applied to a scratch copy of the package, and the test file must fail on each. All 36 are killed. 34 were killed at
58af8c52. The other two showed gaps in the tests, not in the code: a fraction inside a mixed identifier column, and a decile whose households all have zero weight.4892db98adds a test for each, and both mutants are now killed. Without the zero-weight guard the formula raisesZeroDivisionError. The mutants cover:datasetleft asNone, and the rounding warning removed;Earlier runs on earlier heads found two more gaps the same way: a January column taken as the whole year, and a float in a mixed
person_column that names the wrong group.The docs example, run as written.
test_subsample.pyfrom Give UK simulations a working subsample #2265, on a local merge of the two branches at58af8c52, with the replacement test (in "Interaction with Give UK simulations a working subsample #2265") applied: 38 passed.Not run locally: the full suite and the docs build (CI runs both).
🤖 Generated with Claude Code