Map the stored WIC take-up draw onto takes_up_wic_if_eligible when loading US data - #531
Merged
Merged
Conversation
policyengine-us 2.x renamed the WIC take-up input from would_claim_wic to takes_up_wic_if_eligible. The certified US default release stores the draw under the old name only, so every load path skipped it and every WIC-eligible person took WIC up (PolicyEngine/microcosm#1026). Add a register of renamed stored inputs, LEGACY_INPUT_RENAMES in tax_benefit_models/us/legacy_inputs.py, applied by one function. A rename applies only when the data stores the old name, the engine lacks it but defines the new one, and the data does not already store the new one. It then sets the new input from the stored draw for every month of every dataset year, after checking that each stored table is in the simulation's order. Simulation.run(), managed_microsimulation() and create_datasets() apply it, including a reform's baseline branch. Record the renames applied in output metadata, release_bundle, saved US output files, run records, policyengine_bundle and created datasets. Saved US outputs without the record predate the fix, so load() refuses them and ensure() recomputes them. Fixes #530 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #531 found that year files ensure_datasets or create_datasets wrote under 6.0.0 to 6.1.1 had lost the WIC take-up draw, and that ensure_datasets kept reusing them. - PolicyEngineUSDataset.save() writes the renames record into the file and load() restores it, so create_datasets year files keep it. - ensure_datasets creates year files without the record again, and load_datasets refuses them. - Simulation.run() carries the input dataset's record into its output, so a run over a year file shows the rename applied when it was cut. - Simulation.run() refuses a core H5 that stores the legacy draw for part of a year, as managed_microsimulation already did. - Tests cover the reform baseline on run(), a later core H5 year that stores its own person IDs, the record round trip, stale year files and part-year draws. - The docs say what an empty record does and does not show, and the changelog gains a changed fragment for the files no longer reused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
fix/wic-legacy-take-up-input
branch
from
September 26, 2026 00:32
a41c8f1 to
7e0ae30
Compare
Simulation.run() refused a core variable/period H5 whose legacy would_claim_wic column was stored for part of a year even when the file also stored takes_up_wic_if_eligible. managed_microsimulation ignores the legacy column in that case, and the mapping is meant to do nothing once the data stores the live name. The part-year check now applies only when the file lacks the live name, on both paths, and a test covers a file that stores both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Merged by the session that opened it, on Max's go (decision d424, 2026-09-26), with head
🤖 Generated with Claude Code |
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 #530
The defect
policyengine-us 2.x renamed the WIC take-up input from
would_claim_wictotakes_up_wic_if_eligible(Person,MONTH,default_value = True), andwicisdefined_forit. Published US releases store the seeded draw under the old name only. That includespopulace-us-2024-spm-20260915, this package's certified default (pinned insrc/policyengine/data/bundle/manifest.json). policyengine-us 2.2.1 defines nowould_claim_wic, so every load path skips the stored draw, and every WIC-eligible person takes WIC up.PolicyEngine/microcosm#1026 measured this with full-population
managed_microsimulation()runs. In 2024, WIC is $11.52B with the draw ignored and $6.63B with it restored; the published runtime gives $6.68B. Recipients are 12.65M and 6.66M. I did not reproduce those full-population figures here (see "What I verified").This implements option 2 of microcosm#1026, as ruled in d271: a load-time rename in policyengine.py.
What changes
policyengine.tax_benefit_models.us.legacy_inputsholds a module-level register,LEGACY_INPUT_RENAMES = {"would_claim_wic": "takes_up_wic_if_eligible"}. One function,apply_legacy_input_renames(simulation, stored_tables), applies it. A rename applies when all of these hold:The live input is then set from the stored draw, as a bool, for every month of every dataset year. The mapping switches itself off once the data stores the live name or the engine defines the legacy name again. No load path mentions WIC.
The mapping covers every place in
src/that builds a US simulation from stored data:Simulation.run()(PolicyEngineUSLatest._build_simulation_from_dataset). Before this change, its input loop calledset_inputonly for columns insystem.variables. It now passes the entity tables, already reindexed to the simulation's IDs, to the mapping. This runs for the reform simulation and for its baseline (test_run_maps_the_baseline_of_a_reform_too). Region-scoped copies keep the column. For policyengine-corevariable/periodH5 files, the loader used to guess a column's entity from its length and dropped a column whose length matched two entities. It now places a pending legacy column on its live input's entity. It also refuses a legacy column stored for part of a year, as the managed path does, instead of reading the first stored month for the whole year. A file that also stores the live name is loaded natively on both paths, whatever period its legacy column is stored for.managed_microsimulation(). policyengine-us loads an entity-table H5, the certified default's layout, into aUSMultiYearDataset. Its per-year DataFrames keep the columns the engine skipped. For a policyengine-corevariable/periodfile, the mapping reads only the ID and legacy columns again fromdataset.file_path. Before setting anything, each stored table'sperson_idmust equal the simulation's population IDs in order; otherwise the mapping raisesValueError. It maps every year policyengine-us extended the data to. It also maps branches that already exist, since a reform'sbaselineis branched off during construction.create_datasets(), and soensure_datasets()when it creates year files. This path builds apolicyengine_us.Microsimulationfrom the source file, so it gets the same treatment. Each year file now storestakes_up_wic_if_eligibleand the renames record.Other simulation constructions in
src/do not load stored US data.household.pybuildspolicyengine_us.Simulationfrom a situation dict, and the UK paths use policyengine-uk.Where the record lives
Each run records the renames it applied as
{legacy: live}, or{}when none applied. All of these are plain JSON-serialisable dicts:simulation.output_dataset.metadata["legacy_input_renames"]andsimulation.release_bundle["legacy_input_renames"]. A run over acreate_datasets()year file includes the renames applied when that file was cut, so the chain shows the rename even though the year file already stores the live name;PolicyEngineUSDataset.save()(saved US outputs andcreate_datasets()year files), in an H5 datasetpolicyengine_legacy_input_renames, whichPolicyEngineUSDataset.load()restores intometadata;results.jsongainslegacy_input_renames;managed_microsimulation().policyengine_bundle["legacy_input_renames"].{}means no stored column was mapped. It does not certify that the data carried a take-up draw: data that stores neither name runs with the live input's default, which is full take-up. The docs say so.Behaviour change: files written before this fix are not reused (
changelog.d/530.changed.md). Their missing record marks them.Simulation.load()now raisesValueErrorfor it, soSimulation.ensure()runs the simulation again and saves the result. DirectSimulation.load()callers get the error. This follows the precedent of saved outputs that lack an SPM receipt.ensure_datasets()orcreate_datasets()wrote under pe.py 6.0.0–6.1.1 stores neither name, so the draw is lost.ensure_datasets()now treats such a file as missing and creates the year files again, andload_datasets()raises for it. A year file opened directly withPolicyEngineUSDataset(filepath=...)is not checked; the docs say so.Invariants
These hold for every input. Each is tested with Hypothesis property tests and example tests:
test_live_input_equals_the_stored_draw_every_month_of_every_year(200 examples: bool, int8, int64 and float64 0/1 draws, 1–4 years, 1–30 people);test_microsimulation_draw_is_preserved_on_every_branch;test_variable_centric_draw_is_preserved_every_month_of_every_year(core H5 files);test_real_engine_takes_up_any_stored_draw_every_month(real policyengine-us 2.2.1 engine, 25 examples)test_run_keeps_a_stored_false_draw,test_run_over_a_core_h5_keeps_a_stored_false_draw,test_run_over_a_region_keeps_a_stored_false_draw,test_managed_entity_table_file_keeps_a_stored_false_draw,test_managed_variable_centric_file_keeps_a_stored_false_draw,test_managed_reform_maps_its_baseline_too,test_create_datasets_extracts_the_draw_under_the_live_nametest_nothing_is_set_when_the_rename_does_not_apply(all three cases);test_only_years_whose_data_lacks_the_live_name_are_mappedtest_pending_renames_follow_what_the_engine_defines,test_variable_centric_file_storing_the_live_name_is_not_read,test_run_leaves_data_that_stores_the_live_name_alone,test_a_core_h5_storing_the_live_name_ignores_a_part_year_legacy_draw(both load paths)test_a_table_out_of_simulation_order_is_refused_and_nothing_is_set(a permutation, a foreign ID, a missing person, an extra person)test_a_table_without_ids_is_refused,test_managed_person_table_out_of_order_is_refused(real engine),test_a_year_storing_its_own_ids_is_checked_against_them(a later year of a core H5 that stores its ownperson_idin another order),test_variable_centric_file_with_a_mislengthed_draw_is_refusedtest_applying_twice_gives_the_same_inputs_as_once;test_real_engine_takes_up_any_stored_draw_every_month(applies twice on the real engine)test_managed_mapping_is_idempotenttest_a_stored_record_reads_back_as_written(50 examples)test_a_saved_output_keeps_the_renames_it_applied,test_create_datasets_extracts_the_draw_under_the_live_name(year file record survives a reload and reaches the run's record)load_datasets,Simulation.load()) or recomputed (ensure_datasets,Simulation.ensure()).test_ensure_datasets_regenerates_a_year_file_cut_before_the_mapping,test_an_output_saved_before_the_mapping_is_not_reusedtest_a_part_year_period_is_refused,test_a_core_h5_with_a_part_year_draw_is_refused_by_run,test_managed_core_h5_with_a_part_year_draw_is_refusedDifferential test.
test_both_load_paths_give_the_same_take_up_and_wicchecks thatSimulation.run()andmanaged_microsimulation()give the same take-up andwicfor the same household.Fix versus no fix. The fixture is one adult with no income in Los Angeles County, an infant whose stored draw is
False, and a two-year-old whose draw isTrue. Each test first asserts that both children are WIC-eligible, withis_wic_eligibletrue andwic_if_takes_upabove zero. With the mapping, the infant getswic == 0and the toddler getswic == wic_if_takes_up.test_run_without_the_mapping_gives_every_eligible_person_wicandtest_managed_without_the_mapping_gives_every_eligible_person_wicempty the register, and then the infant gets a positivewic. All of this runs in CI'smake test: the integration module needs only the[dev]extras and no downloads.Mutation check. I ran five mutants of
legacy_inputs.pyagainst the unit and property tests: no order check, 11 months instead of 12, ignoring an engine that defines the legacy name, ignoring data that stores the live name, and setting inputs before every table has been checked. The property tests killed all five. After review, eight more mutants ran against both legacy test modules: skipping the baseline mapping inrun(),ensure_datasetsignoring the record,load_datasetsaccepting a file without it,save()not writing it,load()not restoring it,run()ignoring the input dataset's record,run()reading a part-year draw from a core H5, and checking every year of a core H5 against the first period's person IDs. Each was killed by the new test aimed at it.What I verified
6496cc43…aee84, the hash the manifest pins. It is an entity-table HDFStore with_time_period = 2024. The person table storeswould_claim_wicas bool, True for 7,470 of 166,321 rows, and has notakes_up_wic_if_eligible.np.random.default_rng(0).choice(household_ids, 600, replace=False), wherehousehold_idsis the household table'shousehold_idcolumn in stored order. That gives 1,700 people. I wrote them to an entity-table file in the certified layout, loaded it withPolicyEngineUSDataset(which projects the household weights onto the other entities), and ranSimulation.run()for 2024. With the mapping, person-weighted WIC was 0.606 of the unmapped figure and weighted recipients 0.620; unweighted, the ratios were 0.535 and 0.492. Of the 65 people withwic_if_takes_up > 0, 32 took WIC up with the mapping and 65 without it. For comparison, microcosm#1026's full-population ratios are 0.576 and 0.526. The sample is small, so it confirms only the direction of the fix and that the fix reaches real data. Peak RSS was 6.0 GiB.create_datasets(years=[2026])wrote a year file whosetakes_up_wic_if_eligibleequals the stored draw for every person, and which records the rename. I then removed the record and the live column, as a 6.1.1-era file would lack them.load_datasetsrefused that file, andensure_datasetsregenerated it with the record. A 2026 run over the regenerated file gave 32 of the 65 eligible people WIC, and itslegacy_input_renameswas{"would_claim_wic": "takes_up_wic_if_eligible"}.Microsimulation.__init__,USSingleYearDataset,USMultiYearDataset,extend_single_year_dataset,takes_up_wic_if_eligible) and policyengine-core 3.32.5 (build_from_dataset,Dataset.from_file,Holder.set_input) in this environment.Not verified
managed_microsimulation()on the certified default: it needs about 60 GiB, and the machine is shared. The full-scale check, which should reproduce microcosm#1026's run C ($6.63B, 6.66M recipients in 2024), is left to a separate run.mypyis informational in CI, andmainalready has errors in files this PR does not touch. The count is under "Commands and results".RENAMES_H5_DATASETsays so.PolicyEngineUSDataset(filepath=...)is not checked for the record. Onlyensure_datasetsandload_datasetscheck it.Commands and results
Local runs used Python 3.14.4 on macOS, with
HF_HUB_OFFLINE=1and no Hugging Face token. Each pytest process ran under a watchdog that caps RSS at 7.5 GB.At
51065c6(a follow-up:Simulation.run()'s part-year refusal now skips a core H5 that also stores the live name, as the managed path already did):ruff format --check .reports "267 files already formatted" (untracked files excluded), andruff check .reports "All checks passed!".test_us_legacy_inputs.py(45),test_us_legacy_inputs_integration.py(23),test_us_native_hdf_weights.py,test_dataset_persistence.py,test_dataset_runtime.pyandtest_us_long_term_datasets.py: 123 passed (peak 3.4 GiB).datasets.pyrestored to7e0ae30,test_a_core_h5_storing_the_live_name_ignores_a_part_year_legacy_drawfails with "only yearly periods are supported"; with the fix it passes.mypy --python-version 3.14 src/policyengine: 121 errors, unchanged.51065c6. Every check passed. Each of Test (3.11), (3.12), (3.13) and (3.14) ran the full suite: 1,188 passed, 9 skipped.At
7e0ae30(the review fixes):ruff format --check .reports "267 files already formatted", andruff check .reports "All checks passed!". ruff 0.16.9 also checks Markdown, so the new changelog fragment raised the count from the 266 that CI's Lint job reported atd4bf360. The earlier body's "267" also counted an untracked agent notes file.tests/test_us_legacy_inputs.pygave 45 passed.tests/test_us_legacy_inputs_integration.pygave 22 passed.test_run_record.py,test_spm_model.pyandtest_us_native_alignment.py: 104 passed (peak 5.3 GiB);test_be_axiom_pilot.py,test_budgetary_impact.py,test_cache.py,test_dataset_persistence.py,test_dataset_runtime.pyandtest_extra_variables.py: 45 passed, 4 skipped;test_labor_supply_response.py,test_release_manifests.py,test_small_follow_ups.pyandtest_us_long_term_datasets.py: 85 passed;test_us_microsim_structural_reforms.py,test_us_native_hdf_weights.py,test_us_program_statistics.pyandtest_dict_reforms_on_simulation.py: 45 passed.7e0ae30; CI's Test jobs run it. Atd4bf360, the local full suite, run in batches of eight files, gave 1,174 passed, 9 skipped and 0 failed. One of those batches (test_us_long_term_datasets.pythroughtest_variable_labels.py) reached the cap intest_us_reform_application.py. Split into three processes, its files passed (61 + 6 + 37).mypy --python-version 3.14 src/policyenginereports 121 errors on this branch and 124 onorigin/main(4dc5959), both measured at this head. None are inlegacy_inputs.py.d4bf360,uv lock --check --offlinewas consistent andpython scripts/release_lock.pypassed. The review fixes do not touchpyproject.tomloruv.lock.🤖 Generated with Claude Code