Repository navigation
Conversation
Both membership variables were declared float, so policyengine-core stored them as float32, which holds whole numbers exactly only up to 2**24. Identifiers near 900 million read back rounded: calculate, to_input_dataframe() and UKSingleYearDataset.from_simulation returned 900000000.0 for three different households, and a simulation built from a policyengine-core dataset file (rebuilt from that export) merged them into one household. Declare both int, like person_id, benunit_id and household_id, so they are stored as int32 and are exact up to 2**31 - 1. Every cached UK dataset's identifiers fit (the largest seen is 967,010,009). build_from_ids now refuses identifiers outside the int32 range or not whole, which the store's astype would otherwise wrap or truncate without an error. Fixes #2267 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Data builds can number identifiers past the int32 range. uk-data's clone_and_assign offsets clone k by k * 10 ** (digits of the largest id), so a local build of the 2024-25 FRS at its default of 10 clones would reach about 9.1 billion (its release and PR builds use one clone). Microcosm's UK build sizes its clone offsets the same way (id_multiplier_for_values) on ids that already reach 967 million, and its policyengine-uk adapter passes frames to UKSingleYearDataset without renumbering them. person_id, benunit_id and household_id already wrap out-of-range ids without an error, while the populations, built from the given arrays, stay exact. Refusing such ids could turn those builds from wrong stored ids into failures, so build_from_ids gives an EntityIdWarning instead. In-range ids are unchanged: they read back exactly and raise no warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 10, 2026
From the review of #2273: - An object array of Python numbers, such as a pandas column mixing integers and floats, skipped the check, and core's astype truncates its fractions. check_entity_ids now converts such an array to the NumPy type that holds its values and checks that. Text and integers beyond 64 bits stay object and are left to core, which converts or rejects them. - The structure invariant for out-of-range identifiers covers UK tables and DataFrames only. build_from_dataset rebuilds a policyengine-core dataset file from the stored identifiers, which have changed and lost their order, so its test now checks only the warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the second review of #2273: - An object array whose cells NumPy cannot line up, such as a nested array, made check_entity_ids raise. It now leaves such arrays to core. - The docstring and invariant 5 now name what is checked: integer and float arrays, and object arrays of Python floats and of integers within 64 bits. Decimal values and larger integers are left to core. - The property tests suppress Hypothesis's too_slow health check, which a slow runner tripped while drawing one input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 #2267
What changes
person_household_idandperson_benunit_idare declaredvalue_type = int, likeperson_id,benunit_idandhousehold_id. policyengine-core then stores them as int32, which holds every whole number from -2,147,483,648 to 2,147,483,647, instead of float32, which is exact only up to 2**24 (16,777,216).Simulation.build_from_idschecks the five identifier columns against the dtype of the variable that stores each one (policyengine_uk/utils/entity_ids.py). An identifier outside the int32 range, or a float that is not a whole number, gives anEntityIdWarning, because core'sHolder._to_arraycasts it withastypeand would store a different number without an error. Object arrays of Python integers and floats (a pandas column mixing them) are converted and checked too; other objects, such as text orDecimalvalues, are left to core's own conversion.docs/book/usage/simulations.mdon the identifier range, and a changelog fragment.The issue's reproduction on
main(a375230) and on this branch:maincalculate("person_household_id")[900000000.0, 900000000.0, 900000000.0](float32)[900000001, 900000002, 900000003](int32)to_input_dataframe(),person_household_id__2025900000000.0UKSingleYearDataset.from_simulation(...).person900000000.0Microsimulation(dataset=<policyengine-core h5 file>), people per household[3, 0, 0][1, 1, 1]The last row is a structural failure on
mainthat the issue did not list.build_from_dataset(Dataset)builds the entities from the file, exports them withUKSingleYearDataset.from_simulation, and rebuilds from that export. The export carried the rounded membership column, so the three one-person households became one household of three and two empty households. That held for float64 and int64 identifiers in the file.Why
int, and what I checked firstThe issue offered two options: declare both variables
int, or keepfloatand give callers an exact source.intmakes every existing reader exact without new API. The other three identifier variables already use it. The checks the brief asked for:Datasets that hold these columns as floats. Core's
Holder._to_arraycasts whatever it is given to the variable's dtype, so a float64 or float32 column of whole numbers loads as exact int32. The property test builds every case from float64 tables as well as int64, and a separate test loads float32 columns. The one cached file that stores identifiers as floats, the policyengine-core layoutenhanced_frs_2022_23.h5, holds float64 identifiers that are all whole and at most 45,010,024. As float32, 106,804 of itsperson_idvalues and 5,184 of itsperson_benunit_idvalues change.Whether any identifier exceeds 231 - 1.** I read the identifier columns of every cached UK dataset file (read-only, h5py and
HDFStore.selecton the ID columns; no simulation). None does. The largest isperson_id967,010,009 inpopulace_uk_2023.h5. That file is also the issue's example: its 535,080 household identifiers are 76,551 distinct float32 values, and its 618,980 benefit unit identifiers are 544,894.enhanced_frs_2024_25.h5(fetched today) reaches 130,163,015 (person_id) and 130,170 (household_id).Build paths that can exceed it, and why out-of-range identifiers warn instead of raising. uk-data's
clone_and_assignoffsets clone k by k × 10^(digits of the largest identifier). For the current release (FRS 2024-25, largest identifier 130,163,015) that is 10^9, so a local build at the default of 10 clones would reach about 9.1 billion. The release and PR workflows setPE_UK_DATA_OA_CLONES=1, so published files keep the original identifiers. Microcosm's UK build sizes its clone offsets the same way (id_multiplier_for_values; the only identifier range checks I found in its UK build are against int64 overflow) on identifiers that already reach 967 million. Its policyengine-uk adapter passes frames toUKSingleYearDatasetwithout renumbering them. In such buildsperson_id,benunit_idandhousehold_idalready wrap onmainwithout an error, while the populations, built from the given arrays, stay exact. An error here could break those builds. A warning keeps them running and names the columns. Follow-up issues are listed below.Metadata API and downstream readers. policyengine-api's
build_variablesreportsvalueTypefromvalue_type.__name__, so both variables now reportintinstead offloat(and core'sjson_typeisintegerinstead ofnumber). Its household calculation serializes an int result with.tolist()instead of_serialize_float. Both variables have default value 0 either way. Readers of these columns that now get exact integers:_year_data_with_weights,_match_records).derive_all_claimants_over_state_pension_agecallers group bysim.calculate("person_benunit_id")(datasets/frs.py,datasets/imputations/frs_only.py). FRS benefit unit identifiers are below 2**24 today, so they were exact there already.No formula under
policyengine_uk/variablesreads either variable (git grep).Round trips.
Simulation.build_from_dataset(Dataset)andUKSingleYearDataset.from_simulationnow round-trip identifiers up to 2**31 - 1 exactly. Tests below.No core change needed. int32 covers every dataset I found. A 64-bit integer store is policyengine-core#467, which is open and covers all integer variables.
Invariants
For identifiers that are whole numbers from -231 to 231 - 1, held as int64 or float64 in the tables, with each benefit unit and household table sorted by identifier and every group having a member (the loaders already require both: an unsorted group table gives each group another group's inputs on
main, #2278, and core's join assigns people to groups by rank; this PR changes neither):calculate,to_input_dataframe()andUKSingleYearDataset.from_simulationreturn all five identifier columns exactly, as integers, in every year of the dataset.person_household_idequalspopulations["household"].ids[members_entity_id], and likewise for benefit units.ids,members_entity_id) is the one the tables describe.build_from_datasetrebuilds from its own export, has that structure and those identifiers.check_entity_idswarns exactly when core's cast to the storing dtype would change a value or fail. It was checked againstastypeitself over int64, int32, uint32, uint64, float64, float32 and such object arrays, including NaN, ±inf, fractions and values at each range edge. Other object arrays (text,Decimal, larger integers, nested arrays) are left to core. Building a simulation from in-range identifiers gives noEntityIdWarning. The test module turns that warning into an error, so every in-range test also checks that.build_from_datasetrebuilds it from the stored identifiers, which have changed and lost their order, so group inputs can land on other groups there. It still warns, on the first build.Tests
New:
policyengine_uk/tests/test_entity_ids.py(pytest + Hypothesis, 30 tests). The property test draws up to four households of up to three benefit units of up to three people. It draws identifiers from the whole int32 range and clusters around 2**24, 900 million and both int32 limits, and shuffles the people.Run locally, one process per file,
-p no:cacheprovider, a fresh--basetempdeleted afterwards. The other three files ran at 608a859; the last commit changes only object-array handling and the new test file.test_entity_ids.pytest_input_records.pytest_simulation_dataset_sources.pytest_date_of_birth.pytest_subsample.pyis not onmain(it comes with #2265). Mutation checks (at 608a859, 29 tests): withvalue_type = floatrestored, 20 fail. With the identifier check disabled, 16 fail (the warning and check tests).ruff format --check .andruff check .pass on the whole repo.Not run locally: the full suite and the docs build. CI runs both.
Interaction with #2265
#2265's
test_identifiers_beyond_float32_precision_keep_their_householdsasserts a precondition,len(set(stored person_household_id)) < len(households): the stored values must not tell its 900-million households apart. This PR makes them exact, so that assertion fails once both PRs are merged. On a local merge of the two branches (this head, 608a859, with #2265's fa7b0f0), that line is the only failure intest_subsample.pyandtest_entity_ids.py(66 passed, 1 failed). The test's own checks, thatsubsamplekeeps whole households, all pass. Whichever PR lands second should change that line to assert the stored values are exact, and update the sentence inpolicyengine_uk/utils/subsample.py's module docstring that says the model stores those columns as 32-bit floats.Review
Independent review (Subfleet
--task review --tier hard, served by Claude Opus 5.5 with read-only tools) at 608a859: APPROVE, no blocking findings. Its findings and what changed:stacklevelpoints inside the library: left, sinceskip_file_prefixesneeds Python 3.12 and the package supports 3.11.extend_single_year_datasetcopies them unchanged (its upraters write no identifier column), and the structure comes from the first year. A hand-builtUKMultiYearDatasetwhose later years use different identifiers is not checked.A second review at 2d465a9 (Subfleet
--task review --tier hard, served by GPT-6.1 Sol on a Codex lane): APPROVE, no blocking findings. Changed in bef1bae: nested object cells no longer make the check raise; the docstring and invariant 5 name exactly what is checked; the preconditions above add "every group has a member"; the later-years point above is corrected; the property tests suppress Hypothesis'stoo_slowhealth check, which the reviewer's slow sandbox tripped while drawing one input. Not changed: a false warning the object conversion could give for an int64 target (all five identifiers are int32).Follow-ups
clone_and_assign) and UK clone stages can number identifiers past the 32-bit range policyengine-uk stores microcosm#1194 (the UK clone stages).axiom: n/a: simulation infrastructure, no policy rule change
🤖 Generated with Claude Code
Reproducer:
policyengine_uk/tests/test_entity_ids.py::test_identifier_variables_are_stored_as_integers,policyengine_uk/tests/test_entity_ids.py::test_identifiers_in_range_read_back_exactly,policyengine_uk/tests/test_entity_ids.py::test_core_dataset_file_builds_the_tables_structure,policyengine_uk/tests/test_entity_ids.py::test_issue_reproduction_reads_back_in_every_year,policyengine_uk/tests/test_entity_ids.py::test_issue_reproduction_keeps_three_households,policyengine_uk/tests/test_entity_ids.py::test_core_dataset_file_keeps_households_apart