Skip to content

Bake per-state ACS-local year files into the main path's worker image - #879

Merged
anth-volk merged 16 commits into
mainfrom
feat/state-datasets
Oct 21, 2026
Merged

anth-volk merged 16 commits into
mainfrom
feat/state-datasets

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #878

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

US state and congressional-district requests no longer download the 9.8 GB ACS-local dataset, or prepare it for every state, on each new worker. The precompute prepares one year file per state for 2025–2027, and the image includes them. Each commit is a separate change:

  1. RegionResolution.state_code. Resolving a state or district region now reports the two-letter code of its state. National, UK and region-group resolutions report none. This is a shared helper, and Stage 12 is unaffected.
  2. _load_dataset(data_folder=...). Callers can choose the folder a year file is read from. If they don't, the folder is chosen as before. This is a shared helper.
  3. Content key sds1 and folder layout.
    • The key covers the regional dataset's name, its revision-pinned URI and certified SHA-256, the year, the state code and FIPS code, the filter variable, the model and .py versions, and the SPM selection.
    • Store path: state-datasets/us/<digest>/<stem>_year_<year>.h5.
    • Image folder: state-datasets/<dataset>/<STATE>.
  4. Plan. state_dataset_targets() resolves every state region the way a request for it would be resolved. It keeps only the states whose dataset is not the default. The plan lists one entry per year and state, in a fixed order. A bundle that maps states back to the default dataset plans none.
  5. Build. build_state_datasets_impl:
    • re-derives each key inside the container;
    • has .py prepare the full-country year file once, then releases that memory and reloads the one year;
    • refuses data in which a household's district code names a registered district of another state (an unassigned code such as 0 matches no district and is allowed);
    • for each state, filters with the state's own scoping strategy and writes the file with .py's save();
    • copies the stamp, which save() drops, and confirms .py's reuse checks accept the file;
    • uploads the file write-once. Weights are unchanged.
  6. Image fetch. A manifest entry with a folder is placed in that folder. Absolute or unsafe folders and filenames are refused before anything downloads, as are repeated files. The fetch timeout rises from 900 s to 3,600 s.
  7. Precompute run. build_state_datasets runs with 8 CPUs, 96 GiB and a two-hour limit, one container per year and dataset. It starts alongside the dataset builds. The run waits for it only before publishing the manifest, so baselines never wait. For each build it logs the files built and uploaded, the bytes uploaded and the time taken.
  8. Manifest. The manifest gains state_dataset entries, each with folder and state_code. They use their own model, chosen by type. A plan with no per-state files publishes byte for byte the same manifest as before.
  9. Runtime. A state or district request reads its state's baked file when the image has it for the requested year and .py's reuse checks accept it (or can be run at all). Otherwise it prepares the dataset during the request, as before, so .py never rebuilds the regional dataset inside a baked folder. The run records dataset_source as baked_state or on_demand.

Each state file holds exactly the rows the run-time state filter would keep from the full-country file, and carries the same stamp and records. Results are therefore unchanged.

First deploy. The precompute builds 153 per-state files (51 states × 3 years), each from a 9.8 GB source, and the image copies in several GB more.

Testing

  • Test-first. Each commit was written test-first; every new test was run and seen to fail for the expected reason before the code was written.

  • Real .py year files. The per-state build tests write a three-household year file with .py's own save(), version stamp and SPM record. They show that .py's real ensure_datasets reuses a state file without rebuilding it. Re-applying the state filter keeps the same rows, and weights are unchanged.

  • Executor suite. uv run pytest tests/ in projects/policyengine-simulation-executor: 1,640 passed, 22 skipped.

  • Code review. The review-code review found no bugs. Its points are addressed in the last four commits, each test-first:

    • the district-code check narrowed to codes that name another state's district;
    • builds grouped by year and dataset;
    • the log reports bytes actually uploaded and build times;
    • a district request is tested from region resolution to the folder it loads;
    • each state file is compared with .py's filter applied to the full source data;
    • the target's row filter is typed, and a target without one is refused.

    A second round found no bugs. Its points are addressed in the last two commits:

    • a state without a row filter is refused before the year file is prepared;
    • if a .py upgrade moves the private helper the reuse checks import, requests load on demand instead of failing;
    • docstrings corrected.
  • Quality checks. python scripts/run_quality_guards.py: passed.

  • Pyright. python -m scripts.rules pyright (pinned 1.1.414) passes for the executor. Main started running Pyright (Run Pyright in CI for every package, in its own locked environment #864) after this branch was written, and the last commit fixes the seven errors the branch added:

    • the run's spans now open with scopes.span(runtime, ...), as docs/engineering/skills/type-checking.md says, and _build_simulation is annotated with the StageReportingSimulation it returns;
    • the precompute app casts its Modal functions to RemoteFunction;
    • the manifest list and the district-code check are typed correctly.

    This also clears 54 existing errors, so the executor baseline drops from 178 to 124 entries. That changes rules/, which needs code-owner approval.

  • Ruff 0.15.22 (the version CI pins): ruff check . and ruff format --check projects/*/src libs/*/src are clean.

  • Not run locally: the Modal image smoke, the Docker build and the local integration tests. CI runs them.

  • Planned after the beta deploy:

    • check the precompute log for 153 uploads and their total size;
    • check the image fetch finishes within its time limit;
    • run Utah and California requests and confirm dataset_source=baked_state with dataset_load in seconds;
    • compare those outputs with the on-demand path.

🤖 Generated with Claude Code

anth-volk and others added 16 commits October 22, 2026 01:40
RegionResolution gains state_code: the two-letter code of the US state a
state or congressional-district region lies in, taken from the .py region.
National, UK and region-group resolutions leave it None. The baked
per-state datasets added next are looked up by this code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_load_dataset takes an optional data_folder that replaces the folder the
dataset selection would otherwise use (the image's data folder for the
default dataset, a temporary folder for any other). Callers are unchanged;
the main path and Stage 12 will each pass the folder of a baked per-state
dataset when one exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A per-state dataset is a regional dataset's year file filtered to one
state's households. Its content key (schema sds1) covers the regional
dataset's name, revision-pinned URI and certified SHA-256, the year, the
state code and FIPS, the filter variable, the model and .py versions and
the SPM selection. Its store path is
state-datasets/<country>/<digest>/<stem>_year_<year>.h5, keeping the
basename ensure_datasets resolves.

state_datasets.py gives each state's folder under the image data folder,
state-datasets/<dataset>/<STATE>, so the manifest builder and the runtime
lookup agree on where a state's files live. The default dataset's key and
identity are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
state_dataset_targets resolves every US state region exactly as a request
for it is resolved and keeps the states whose dataset is not the bundle
default, with their row filter (state_fips) and state code. The plan lists
one StateDatasetPlanEntry per precompute year and target state, year-major
and by state code, with its key, store path, runtime filename, image folder
and store presence; select_work keeps the missing ones. A bundle that sends
states back to the default dataset plans none.

Nothing builds or publishes these entries yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_state_datasets_impl re-derives each planned state's key in its own
container and refuses to build when the planner disagreed, then has .py's
ensure_datasets prepare the regional dataset's full-country year file once,
collects garbage, and reloads that one year. It refuses data in which a
household's congressional district code names another state, since
district requests read their state's file. For each state it keeps the
rows the state region's own scoping strategy keeps, writes them with .py's
save(), copies the year-file stamp that save() drops, confirms that .py's
reuse checks (stamp and renames record, then the legacy SPM record) accept
the file, and uploads it write-once. Weights are unchanged.

The tests write a three-household year file with .py's own save, stamp and
SPM record, and show that .py's real ensure_datasets reuses a state file
without rebuilding it. The identity stubs now import .py's US dataset module
before patching the manifest, so a stubbed test cannot bind the stub into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image's artifact fetch now puts an artifact with a "folder" at
<data folder>/<folder>/<filename>, so per-state datasets, which all share
the year file's basename, land in their own state folders. Artifacts
without a folder still go straight into the data folder. Before anything
downloads, it refuses absolute folders, folders with "." or ".." parts,
empty parts or backslashes, filenames that are not bare names, and two
entries for the same file.

The fetch layer's timeout rises from 900 s to 3,600 s for the several GB
of per-state datasets. No manifest lists such artifacts yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The precompute app gains build_state_datasets (8 CPUs, 96 GiB, two hours),
one container per year, validating plan entries in and dumping build
results out like the other wrappers. run_precompute spawns the state builds
with the dataset builds and waits for them only before publishing the
manifest, so baseline computation never waits for them, then reports how
many state datasets were built and uploaded and their total size.

The manifest does not list the state datasets yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_manifest appends a state_dataset entry for every planned state file,
with its folder and state code, after the datasets and baselines. The new
entries use their own StateDatasetArtifact model, chosen by "type", so a
plan without per-state datasets publishes byte-for-byte the manifest it
always did (the pinned wire-shape test is unchanged). The deploy-time
manifest validation accepts the new entries and hands them to the fetch
layer, which places each in its state's folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before loading a non-default dataset, the main path's runtime asks
state_datasets.baked_state_data_folder for the image folder of the
request's state (a district passes its state's code). It returns the folder
only when the image holds that state's year file for the requested year
and .py's reuse checks (stamp and renames record, legacy SPM record) accept
it; a file .py would rebuild is skipped, so ensure_datasets never rebuilds
the full regional dataset inside a baked folder. _load_dataset then reads
from that folder, and the district filter still applies at run time.
Otherwise the request prepares the regional dataset on demand, as before.

Runs whose region loads a non-default dataset record dataset_source as
baked_state or on_demand. National and default-dataset runs are unchanged,
and Stage 12 still loads without a folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-state build refused the whole year whenever any household's
congressional_district_geoid did not start with its own state FIPS,
including codes that name no district, such as an unassigned 0. Such a
household matches no district request on either path, so it cannot change
a result, yet it would have stopped the precompute and the deploy waiting
on it. The build now refuses only codes that name a registered district
of a different state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_precompute grouped state entries by year only, while one build
prepares a single dataset's year file and refuses entries for two. A bundle
sending different states to different datasets would have failed every
year's build; builds are now grouped by year and dataset.

Each state build result records how long the year's build had run when the
file was uploaded, and the precompute log now reports, per build, how many
files were built and uploaded, the bytes actually uploaded (files already
stored no longer count), and the build time, for tuning the two-hour limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A district request is now followed from region resolution, with the real
dataset selection and baked-folder lookup over a stamped Utah file, to the
folder _load_dataset is given; the old test only repeated a state lookup.
Each state file's rows are now compared with .py's filter applied to the
full source frames, instead of with the file filtered again, which would
not have noticed another state's rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StateDatasetTarget.scoping_strategy is typed as an optional
RowFilterStrategy instead of Any, and a build whose target has none raises
a clear error before writing anything, instead of failing on None. A
comment in artifact_keys now says that per-state datasets are fetched into
their state's folder rather than straight into the data folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The baked-folder lookup runs .py's reuse checks, one of which imports a
private .py helper. If a .py upgrade moved it, every state and district
request would have failed with ImportError while loading its dataset. The
lookup now logs a warning and returns no folder, so the request prepares
its dataset on demand as it would without a baked file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check for a state target without a row filter ran inside the per-state
loop, after the full-country year file was prepared and earlier states
were uploaded. _checked_state_targets now refuses it with the other
in-container checks, before anything is prepared, and returns each
state's row filter. Docstrings now say builds run one container per year
and dataset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main now runs Pyright on every package (#864). Against the executor's
baseline, this branch added seven errors:

- The run's new reads of `dataset_selection`, `region_resolution` and
  `simulation_params` were reported as possibly unbound, because
  `runtime.span(...)` is typed as possibly suppressing exceptions. The
  run's spans now open with `scopes.span(runtime, ...)`, as
  docs/engineering/skills/type-checking.md says. `_build_simulation` is
  annotated with the `StageReportingSimulation` it returns, which the
  progress callback needs.
- `build_state_datasets` couldn't be passed as a `RemoteFunction`,
  because Pyright doesn't resolve `modal` in src/modal to the Modal
  package. The precompute app casts its six Modal functions to that
  protocol.
- The manifest's artifact list is built with `ManifestEntry` elements,
  and the district-code check passes `isin` a sorted list.

54 baseline entries no longer occur, so the baseline drops from 178 to
124 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/state-datasets branch from 9eb6790 to aaa97c8 Compare October 21, 2026 21:47
@anth-volk
anth-volk marked this pull request as ready for review October 21, 2026 22:00
@anth-volk
anth-volk requested a review from a team as a code owner October 21, 2026 22:00
@anth-volk
anth-volk merged commit c94bbd3 into main Oct 21, 2026
22 checks passed
@anth-volk
anth-volk deleted the feat/state-datasets branch October 21, 2026 22:00
anth-volk added a commit that referenced this pull request Oct 21, 2026
Without extraPaths, Pyright adds the executor's src directory as an
import root, so `import modal` in the Modal app modules under src/modal
resolved to that directory, and every modal attribute was unknown. At
runtime src is never an import root: the package is installed from src,
and the app modules import each other as src.modal.<module> from the
project directory, which Pyright still resolves. `extraPaths = []` in
the executor's [tool.pyright] turns the src root off. Only Pyright reads
it; nothing about how Modal loads the apps changes.

That removes 42 errors and exposes 4 the shadowing hid. The previous two
commits fixed 3 of them (the routing store and the batch call id). This
one fixes the fourth: the segmented national child spawn opens its span
with scopes.span, so Pyright no longer reports the method as missing a
return.

The precompute app already casts its six Modal functions to
RemoteFunction (#879), so passing them to PrecomputeFunctions reports
nothing. Its comment said Pyright doesn't resolve `modal` there; it now
says why the casts are still needed: each modal.Function's `remote` and
`spawn` take that function's own parameters, which Pyright doesn't
accept for RemoteFunction's `*args: Any`.

The executor's baseline drops from 119 entries to 77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 21, 2026
#879 already opens the annual run's eight request, dataset, policy and
simulation-build spans in simulation_runtime.py with scopes.span, and
annotates _build_simulation as returning the StageReportingSimulation
that _report_calculation_start needs. This commit opens the ninth span,
credential setup in setup_gcp_credentials, the same way, and documents
why _build_simulation returns a StageReportingSimulation in both
branches (ArtifactBaselineSimulation subclasses it).

Neither change alters what a run does, and the executor's baseline stays
at 77 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 21, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prepare per-state ACS-local year files in the precompute instead of on each new worker

1 participant