Skip to content

Require policyengine-core 3.32.12, whose branches share cached arrays - #2054

Merged
MaxGhenis merged 5 commits into
mainfrom
core-3-32-12-shared-branch-arrays
Oct 3, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
core-3-32-12-shared-branch-arrays

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2109

What

  • Raises the dependency floor from policyengine-core>=3.32.9 (set in Require policyengine-core 3.32.9 and stop exporting HF_TOKEN in CI #1898) to >=3.32.12, and locks 3.32.13. The uv.lock diff against main is the core version and hashes, the specifier, and the lock's own policyengine-uk entry caught up with pyproject's version. uv also wanted to rewrite two equivalent cffi markers under argon2-cffi-bindings; they are left as main has them, and uv lock --locked passes.
  • Adds three run-time cases to the cached-array guard test (policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py, from Copy cached arrays before writing in scenario modifiers #1988). They run the marginal tax rates (earnings and capital gains), the labour supply responses and the capital gains realisation response with every cached array made read-only. The fixture now freezes arrays as storage returns them as well as when it stores them, so a branch's first-read copies and the copies Simulation.clone() makes are covered too.
  • Adds a towncrier fragment.

Why

policyengine-core 3.32.12 (released 2026-10-02) includes PolicyEngine/policyengine-core#556. Before it, Simulation.get_branch deep-copied every cached array into the new branch. Now the branch starts with read-only views of the simulation's arrays and copies each one only the first time it reads it. Simulation.clone() still copies everything. 3.32.13 (2026-10-03, PolicyEngine/policyengine-core#578) keeps that behaviour and stops giving every storage its own empty set of shared keys, which 3.32.12 had added (about 1.3 MB per simulation, per core's changelog). The lock takes 3.32.13; the floor is 3.32.12, the first release with shared branch arrays. This package branches simulations in:

  • marginal_tax_rate and marginal_tax_rate_wrt_employer_cost;
  • marginal_tax_rate_on_capital_gains and the capital gains realisation response;
  • the labour supply responses;
  • the CPS marriage-neutral income tax reform;
  • and, once Lift the tax credit income test for State Pension Credit #2034 lands, the Pension Credit passport in tax_credits_applicable_income.

The floor rises, rather than only the lock, so that pip installs, which ignore uv.lock, also get this. #2034's verification measured a labour-supply reform on the Enhanced FRS (2024, maximum resident set size under /usr/bin/time -l):

without #2034 with #2034
core 3.32.9 5.13 GB 7.67 GB
core 3.32.12 5.10 GB 5.15 GB

The 3.32.10 and 3.32.11 releases in between allow pytest 9 and warn when a restricted Hugging Face download has no HUGGING_FACE_TOKEN. The policyengine package (6.2.1) pins exact versions of both core and this package in its extras, so the new floor does not change what it installs.

Results unchanged

Core documents one behavioural difference. Code that writes in place into a simulation's cached array after branching (x[mask] = 0, x += 1) now also changes what a branch reads, if the branch has not read that array yet. #1988 removed the writes of this kind that its static scan finds, and runs that scan in CI. The new run-time cases add a check on the branching paths themselves. Each of four in-place writes injected after a get_branch call (marginal rate, capital gains marginal rate, labour supply, capital gains response) fails its case with ValueError: output array is read-only. A fifth sits in the labour supply baseline branch, after its nested measurement branch exists. It writes into an array that branch got by a first-read copy (3.32.12) or a deep copy (3.32.9). The extended fixture catches it on both versions; the earlier freeze-on-store fixture missed it on both.

Each comparison below is two real Microsimulation runs on the same commit, in two environments that differ only in policyengine-core (3.32.9 and 3.32.13; same numpy 2.1.3, pandas 2.3.1, Python 3.13.9). They used a private copy of the Enhanced FRS 2024-25, whose hash is unchanged by every run. Every array kept (household, benefit unit and person level) must have the same dtype, shape and bytes, and every weighted total must be exactly equal. Arrays include:

  • household_net_income, gov_spending, household_benefits, household_tax, HBAI income;
  • every benefit in HOUSEHOLD_BENEFIT_VARIABLES;
  • Universal Credit, Pension Credit, Housing Benefit, tax credits, Child Benefit;
  • income tax, National Insurance, State Pension, PIP;
  • poverty flags;
  • each mode's own outputs.
Run Branches left on the simulation Arrays per year Weighted totals per year 2025-2030
Baseline (no branch) none 89 83 all bitwise identical
marginal_tax_rate adult_1_pay_rise, adult_2_pay_rise 90 84 all bitwise identical
marginal_tax_rate_wrt_employer_cost, marginal_tax_rate_on_capital_gains adult_1_employer_cost_mtr, adult_2_employer_cost_mtr 91 85 all bitwise identical
Labour supply reform baseline, lsr_measurement 92 86 all bitwise identical
Capital gains realisation response reform none 93 87 all bitwise identical
Marriage-neutral income tax reform originally_split_income, split_income 90 84 all bitwise identical

Baseline totals, identical on both versions (£bn):

2025 2026 2027 2028 2029 2030
household_net_income 1,710.925546 1,763.540818 1,801.897372 1,839.422293 1,877.639229 1,917.003426
gov_spending 546.579803 564.185454 576.447207 586.445325 596.768486 602.873623
household_benefits 547.261695 564.663935 576.937577 586.946654 597.275988 603.393144
household_tax 499.838909 527.023746 553.588582 576.579503 599.695985 624.788953
universal_credit 74.824298 78.907449 80.718239 82.276543 83.410341 80.349217
state_pension 125.987368 133.690512 140.476649 145.549103 150.792447 156.454406
pension_credit 6.422405 7.134352 7.073964 7.046225 7.147506 6.663522
housing_benefit 13.497797 14.218852 14.592672 14.647796 15.008459 15.350705
child_benefit 16.984427 17.699471 18.173514 18.611103 19.066836 19.535646
pip 27.487576 28.645153 29.412354 30.120555 30.858117 31.616851

A CRC audit on 3.32.13 of the arrays stored through InMemoryStorage.put, re-checked before each new branch was created and at exit, detected no change among the registered arrays still alive at those checkpoints: 12,707 arrays stored across 2 branches in the marginal_tax_rate run and 29,149 across 7 branches in the labour-supply run, 0 changed.

The labour-supply run creates and reads its branches, but on main its responses are exactly zero in every year, on both versions: the baseline side of the measurement is a branch of the reform simulation (simulation.get_branch("baseline")), which carries the reform's parameters, the pattern #1803 fixed for capital gains. That is a separate fix. The capital gains realisation response run is a branching path whose output does depend on its branches: −£0.87bn in 2025 to −£1.10bn in 2030, identical on both versions.

Memory

Peak memory under /usr/bin/time -l on the same Enhanced FRS copy, one process at a time. Max RSS is reported alongside peak memory footprint, because under memory pressure macOS compresses a process's older pages and max RSS under-reports. GB = 10^9 bytes.

Run Years Peak footprint, 3.32.9 Peak footprint, 3.32.13 Change Max RSS, 3.32.9 Max RSS, 3.32.13 Instructions, 3.32.13 vs 3.32.9
Baseline (no branch) 2024 2.26 / 2.26 / 2.27 GB 2.23 / 2.30 / 2.31 GB no change (within run-to-run spread) 2.49 / 2.49 / 2.46 2.48 / 2.48 / 2.51 +0.0%
marginal_tax_rate 2024 3.29 / 3.32 / 3.34 GB 2.67 / 2.78 / 2.71 GB -0.60 GB (-18%) 3.55 / 3.54 / 3.60 2.94 / 2.95 / 2.94 -0.2%
Labour supply reform 2024 6.95 / 6.97 / 7.00 GB 4.92 / 4.89 / 4.84 GB -2.09 GB (-30%) 7.19 / 7.23 / 7.23 5.10 / 5.12 / 5.11 -1.0%
marginal_tax_rate 2025-2030 4.33 GB 4.07 GB -0.26 GB (-6%) 4.54 4.32 -0.1%
Labour supply reform 2025-2030 9.58 GB 8.38 GB -1.20 GB (-13%) 9.86 8.75 -0.1%

Three repeats per 2024 row (a / b / c), one run per 2025-2030 row; macOS memory pressure was normal (level 1) before and after every run. Both environments have every package byte-compiled, and instructions retired match within 2%, so both sides do the same work. (A first pass had only one environment compiled; the uncompiled side spent about 15% more instructions and 0.2 GB more on imports alone, so those numbers were discarded.) Wall time is not compared: the host is shared with other work.

Tests

  • YAML suite, uv run --frozen --extra dev policyengine-core test policyengine_uk/tests/policy -c policyengine_uk: 1,381 passed with core 3.32.12 (head f383f6e), and 1,381 passed on main at the time (908da84, core 3.32.9).
  • Full pytest suite, uv run --frozen --extra dev pytest policyengine_uk/tests/ with HUGGING_FACE_TOKEN set, so the microsimulation tests ran: 470 passed, 1 skipped (policyengine_bundles is not installed), 2 xfailed (head f383f6e, core 3.32.12).
  • The guard test file on this head (0d279df): 35 passed with core 3.32.13 (uv run --frozen) and 35 passed with core 3.32.9, so on these paths core itself does not write into the arrays storage returns.
  • Mutation checks of the new cases: an in-place write injected after get_branch in marginal_tax_rate, in marginal_tax_rate_on_capital_gains, in the labour supply response, and between the two capital gains measurements each fails the matching case (ValueError: output array is read-only; head f383f6e, core 3.32.12). The nested-branch write described above is missed by the freeze-on-store fixture and caught by the extended one, with core 3.32.13 and with 3.32.9.
  • CI runs both suites on the PR head. The Enhanced FRS comparisons, the audit and the memory runs above ran at 0d279df.

Invariants

  • Intended invariant: for any dataset and reform, outputs under 3.32.12 and later equal outputs under 3.32.9 bit for bit, unless code writes in place into a cached array after branching. Checked differentially on the Enhanced FRS above with 3.32.13 (and earlier with 3.32.12), across six modes and six years, for the saved arrays and totals listed (not every intermediate array). CI cannot run this check, because it installs one core.
  • Its precondition is checked in CI by the static scan from Copy cached arrays before writing in scenario modifiers #1988 and by the read-only run-time cases. The scan looks inside each function and cannot see aliasing through containers or helper functions. The run-time cases cover four of the six branching paths; the employer-cost marginal rate and the marriage reform are covered by the static scan and the Enhanced FRS comparison only.
  • No property-based test is added: the PR changes no model logic, and the property only exists across two core versions.

axiom: n/a: dependency floor and tests only, no policy change.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 2, 2026 07:46
policyengine-core 3.32.12 (PolicyEngine/policyengine-core#556) makes
Simulation.get_branch share the simulation's cached arrays with the new
branch and copy each one only on first read, instead of deep-copying every
array up front. Marginal tax rates, labour supply responses and the capital
gains marginal tax rate all branch, so they stop paying for copies their
branches never read.

Raise the floor from 3.32.9 and relock (uv.lock moves core 3.32.9 ->
3.32.12; its entry for this package catches up from 2.102.3 to 2.104.7).

The one behavioural difference core documents is a write in place into a
cached array after branching, which a branch that has not read that array
yet would now see. Extend the run-time half of the cached-array guard test
to run the branching code paths (marginal tax rates, labour supply
responses, capital gains realisation response) with every stored array
read-only, so such a write fails the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #2054 found that freezing arrays only in InMemoryStorage.put
misses arrays that reach a storage without it: the copy a branch makes on
its first read of a shared array (core 3.32.12) and the copies
Simulation.clone() makes. A write into one of those after a nested branch
exists (the labour supply measurement under the "baseline" branch) passed
the run-time guard on both core versions; with the fixture also freezing
what InMemoryStorage.get returns it fails, and the unmutated file still
passes on both.

Shorten the changelog fragment to one sentence, as docs/engineering/skills/github-prs.md asks.

Fixes #2109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #2115 relocked uv.lock; take it and move only policyengine-core,
from 3.32.9 to 3.32.13 (core#578: a storage holds a shared-key set only
while it shares an array, removing the ~1.3 MB per simulation that 3.32.12
added). The floor stays >=3.32.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 3, 2026 16:06
@MaxGhenis
MaxGhenis merged commit 4fccf32 into main Oct 3, 2026
6 checks passed
@MaxGhenis
MaxGhenis deleted the core-3-32-12-shared-branch-arrays branch October 3, 2026 16:06
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Merged at reviewed head 0d279df (merge commit, --match-head-commit), with all six checks passing.

Independent reviews (Subfleet, Codex lanes):

  • r2 approved b5159df with three P3 findings, all addressed in 2808bca and this description.
  • r5 (GPT-6.1 Sol) approved the full delta b5159df -> 0d279df: the fixture change, the merge of main, the core 3.32.13 lock and the regenerated evidence. Of its two P3 nits, the CRC-checkpoint wording is fixed above. A leading - in the changelog fragment was left out on purpose: towncrier's template adds the bullet, and a dashed fragment renders as - - (see Require policyengine-core 3.32.9 and stop exporting HF_TOKEN in CI #1898's entry in CHANGELOG.md).
  • Rounds r1, r3 and r4 were lost to account limits or superseded.

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.

Require policyengine-core 3.32.12 so simulation branches stop copying every cached array

1 participant