Skip to content

Tie UK year files to the source dataset and package versions they were cut from - #601

Draft
MaxGhenis wants to merge 9 commits into
fix/us-year-file-source-identityfrom
fix/uk-year-file-source-identity
Draft

MaxGhenis wants to merge 9 commits into
fix/us-year-file-source-identityfrom
fix/uk-year-file-source-identity

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #600

Draft, stacked on #593 (fix/us-year-file-source-identity, not merged yet; rebased onto its head c7bb48d; this PR's head is a2244c8). The base is that branch, so the diff shows only this PR; retarget to main once #593 merges (merge order: #596 → #594 → #593 → this).

Problem

UK year files ({data_folder}/{stem}_year_{year}.h5) are named after the dataset's logical name, which drops the revision. ensure_datasets reused a year file whenever it recorded a data year, and load_datasets refused one only without it, so year files were reused after the bundle re-pinned a name (enhanced_frs_2024_25, populace_uk_2023, …), after a local source was edited, and after a policyengine-uk or policyengine-core upgrade.

Change

  • Stamp. uk.create_datasets stamps each year file with {"packages": {"policyengine-core", "policyengine-uk"}, "revision": 1, "source": <dataset_source_identity>, "year": <int>} in its own H5 dataset policyengine_uk_year_file_stamp (uk.datasets.YEAR_FILE_STAMP_H5_DATASET). US stamps keep policyengine_year_file_stamp, so a year file of one country never passes as one of the other. The year means a copied or renamed year file is not taken for another year.
  • Check. uk.ensure_datasets cuts the files again, and uk.load_datasets refuses them, unless the file records its data year and its stamp equals year_file_stamp(requested_dataset_source_identity("uk", dataset), year). uk.datasets.year_file_stamp_problem(path, source, year) reads only the stamp and whether the data_year node exists (h5py), never the tables or the source, and names what differs. Link cycles, dangling links and malformed stamps read as out of date. Permission, directory and lock errors are raised.
  • Atomic cut. Each year file is written to a hidden temporary beside its path (.{stem}_year_{year}.<hex>.partial, created 0o666 less umask). After the source re-check (snapshot after materializing; downloads re-hashed, as in Tie US year files to the source dataset and package versions they were cut from #593), each temporary is stamped and then os.replaced onto its path. A path's tables and stamp therefore always come from one cut, even with concurrent cuts. A failed cut writes none of that dataset's year files, leaves the existing ones whole, and removes its temporaries.
  • Guards from Tie US year files to the source dataset and package versions they were cut from #593. Two requested sources that share a stem are refused by create, load and ensure. create_datasets(years=[]) snapshots nothing (mirrors c7bb48d).
  • Shared code. policyengine.provenance.year_file_stamp holds the country-neutral parts: JSON node I/O (read_json_dataset, read_stored_json, write_json_dataset), sentinels, installed_package_versions(names), source_and_package_differences, year_file_sources(country, …), temporary_year_file, publish_year_file. us/legacy_inputs.py and us/datasets.py use them; US behaviour and messages are unchanged (Tie US year files to the source dataset and package versions they were cut from #593's US suites pass with one patch target moved: legacy_inputs.json.loads → json.loads).

Invariants (each property-tested)

  • For every year file and request: ensure_datasets reuses it ⇔ load_datasets accepts it ⇔ it records its data year ∧ its stamp == year_file_stamp(requested source, year, installed packages) (Hypothesis, with an oracle that decodes stored nodes independently of the checker).
  • Totality: any stamp node × any data-year node gives a reason or None, never an error other than permission/directory/lock.
  • Deciding never opens tables, PyTables, the source, the network, or imports policyengine-uk.
  • No path ever holds one cut's tables under another cut's stamp; no stamp names a source that changed between materializing and stamping.
  • No two different sources share year files.
  • Differential: the UK and US checks name source and package differences with identical clauses.

Tests

  • tests/test_uk_year_file_stamp.py: unit + Hypothesis, no engine.
  • tests/test_uk_year_file_stamp_integration.py: real policyengine-uk on the seven-person 2024 fixture. Covers a re-pinned bundle dataset, an edited local source, policyengine-uk / policyengine-core upgrades, a source replaced mid-cut, interleaved concurrent cuts, a failed cut over existing files, save() failing mid-cut, two sources in one call, pre-stamp files, a float year, and file mode.
  • tests/test_year_file_stamp.py: the shared module (publish ordering, temporary mode under several umasks, collision refusal).
  • Local, at 9378b3c: 389 passed across the UK, US, shared, identity, runtime and US-integration modules, and 235 more in the other modules that touch the changed code.
  • CI at a2244c8: all 12 checks pass (Lint, Mypy, changelog, smoke-import on 3.11–3.14, Test on 3.11–3.14, release inputs).

Review and mutation testing

  • Independent reviews (Opus 5.5 via Subfleet; the GPT-6.1 Sol lanes were closed until ~17:00Z). Round 1 approved 4325d16 with a medium finding (concurrent cuts could mis-stamp) and four lows. All were fixed: atomic publish, year in the stamp, a UK stamp name, distinct no-data-year reasons, docs limits. Round 2 requested changes on c6f597b (published files became 0600 via mkstemp; float-year key). Fixed. Round 3 approved 568c9cf; its nits are applied in 9378b3c. A full review of the final head a2244c8 is queued (--task review --tier hard: GPT-6.1 Sol when the Codex lanes reopen at about 23:05Z, they were open only 16:40–17:06Z; Opus if a Claude lane admits it first).
  • Mutation testing: all 44 mutants killed at 568c9cf (check, create_datasets, load/ensure, shared module; unmutated baseline passes). At the final head b8bff75, the create-path mutants were re-run against the integration tests without -x: non_atomic_cut (restores the old write-then-stamp cut) fails 4 tests including the concurrency test; snapshot_never and create_ignores_recheck fail the source-replaced tests.
  • Evidence: ~/PolicyEngine/_reviews/pr601-uk-year-file-source-identity/.

Documented limits

Versions are read from metadata when the files are cut, so an upgrade inside a running process after import is recorded at its new version. numpy's seeded draws for student loan plans aren't recorded. A multi-year source is read as stored. Moving a year file into place needs write access to the data folder and gives it the cutting user's mode and group. Saved UK outputs aren't yet tied to the year-file stamp (follow-up proposed).

Rebase notes

#594 adds code in legacy_inputs.py that uses _read_json_dataset, _write_json_dataset, _ABSENT and _UNREADABLE. These are now imported from the shared module under the same names; add UNREADABLE as _UNREADABLE back when rebasing onto #594. Its _year_file_reasons should call _source_and_package_differences once #593 adds source and packages to it.

axiom: n/a: infra (year-file caching and provenance)

🤖 Generated with Claude Code

MaxGhenis and others added 7 commits October 13, 2026 10:18
UK year files were reused whenever they recorded a data year, so files cut
from one release of a dataset were reused after the bundle re-pinned its
name, after a local source was edited, and after a policyengine-uk or
policyengine-core upgrade. create_datasets now stamps each UK year file,
after its tables and data year, with the source identity, the installed
policyengine-core and policyengine-uk versions and a year-file format
revision. ensure_datasets cuts the files again and load_datasets refuses
them unless the stamp equals the one a file cut now would carry, reading
only the stamp and the data year's presence.

The country-neutral pieces of the US stamp (JSON node I/O, installed
versions, source and package reasons, the shared-stem refusal) move to
policyengine.provenance.year_file_stamp, which both countries use.

Fixes #600

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ensure/load

Hypothesis and example tests in the style of test_us_year_file_stamp.py,
with the data year as an extra dimension, plus a differential test that the
UK and US checks name source and package differences alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integration tests over policyengine-uk on the seven-person 2024 dataset:
a re-pinned bundle dataset, an edited local source, a package upgrade, a
source replaced mid-cut and year files from an earlier release are each
cut again, then reused. Property tests for the shared stamp module, and the
UK year-file section of docs/microsim.md now describes the stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the independent review of 4325d16:

- create_datasets writes each UK year file to a temporary file beside its
  path, and stamps and moves it into place only after the source re-check,
  so two concurrent cuts can no longer leave one cut's tables under the
  other's stamp, and a failed cut leaves the existing files whole.
- The UK stamp records the year it was cut for, so a copied or renamed
  year file is not taken for another year.
- UK stamps live in their own H5 dataset (policyengine_uk_year_file_stamp),
  so a US stamp never passes as a UK one, nor the reverse; the US keeps its
  name and its read/write wrappers.
- A stamped file without a data year gets its own reason.
- Tests: two sources cut in one call, interleaved cuts, a failed cut over
  existing files, publish ordering, year mismatch, country isolation; the
  ensure-warning check no longer uses the checker as its oracle.
- Docs: the new fields and behaviour, and the limits the review found
  (versions read from metadata, numpy draws, multi-year sources).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- temporary_year_file creates the file with os.open(..., 0o666), so the
  umask applies as it does when pandas creates a year file; mkstemp made
  every published year file readable by its owner alone.
- create_datasets carries each result key in the cut list, so a year given
  as a float is published and returned as before.
- Tests: the mode of temporary and published files, a save() failure
  mid-cut (no temporary left, existing files untouched), a float year, and
  a "cut for another year" state in the ensure/load property.
- Docs and changelog: a source change aborts that dataset's year files only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirror #593's c7bb48d: create_datasets with no years snapshots and
re-checks nothing, so the UK runtime tests keep their original mocked
paths. Docs say moving a year file into place needs write access to the
data folder and gives the file the cutting user's mode and group. The
mode assertions skip on Windows, and the float-year test runs ensure and
load after the cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the fix/uk-year-file-source-identity branch from 568c9cf to 9378b3c Compare October 13, 2026 14:18
MaxGhenis and others added 2 commits October 13, 2026 10:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant