Skip to content

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

Draft
MaxGhenis wants to merge 3 commits into
mainfrom
core-3-32-12-shared-branch-arrays
Draft

MaxGhenis wants to merge 3 commits into
mainfrom
core-3-32-12-shared-branch-arrays

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

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 relocks. 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 0.12.13 also wanted to rewrite two equivalent cffi markers under argon2-cffi-bindings; they are left as they were, 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 each stored array made read-only.
  • 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. 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.

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.12; 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,709.876476 1,762.721367 1,800.597711 1,838.168833 1,875.997493 1,915.327835
gov_spending 544.675339 562.256150 573.870097 584.099315 594.045701 600.092038
household_benefits 545.357299 562.734701 574.360491 584.600669 594.553269 600.611250
household_tax 498.983584 525.913965 552.311159 575.486975 598.614999 623.682649
universal_credit 73.263667 77.261689 79.016383 80.523617 81.598400 78.502670
state_pension 125.987368 133.690512 139.800361 144.848210 150.066320 155.700300
pension_credit 6.123928 6.868902 6.888465 6.870305 6.966349 6.477164
housing_benefit 13.456684 14.205004 14.581619 14.931760 15.004828 15.351679
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 of every stored array on 3.32.12 found no array written in place after it was stored: 12,583 arrays stored across 2 branches in the marginal_tax_rate run and 28,859 across 7 branches in the labour-supply run, 0 written in place.

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.12 Change Max RSS, 3.32.9 Max RSS, 3.32.12 Instructions, 3.32.12 vs 3.32.9
Baseline (no branch) 2024 2.44 / 2.44 GB 2.44 / 2.44 GB +0.00 GB (+0%) 2.15 / 1.59 2.27 / 2.26 -0.8%
marginal_tax_rate 2024 3.51 / 3.52 GB 2.89 / 2.90 GB -0.62 GB (-18%) 2.29 / 3.05 1.67 / 2.29 -0.6%
Labour supply reform 2024 7.13 / 7.16 GB 5.02 / 5.02 GB -2.13 GB (-30%) 2.41 / 3.09 1.92 / 2.20 -1.7%
marginal_tax_rate 2025-2030 4.49 GB 4.00 GB -0.49 GB (-11%) 2.07 1.67 +0.4%
Labour supply reform 2025-2030 9.71 GB 8.62 GB -1.09 GB (-11%) 6.34 3.06 +0.3%

Two repeats per 2024 row (shown as a / b), one run per 2025-2030 row; macOS memory pressure was normal (level 1) before every run, and after every run but one, which ended at level 2 (warning). Wall time is not compared: the host was heavily loaded, and loading the same dataset alone took between 12 and 43 seconds across runs.

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 (pre-merge 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 (pre-merge head f383f6e).
  • The guard test file on this head (b5159df), uv run --frozen: 35 passed in 66 s. On the pre-merge head the new cases also pass with core 3.32.9.
  • Mutation check of the new cases (pre-merge head, core 3.32.12): 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).
  • CI runs both suites on this head; the Enhanced FRS comparisons above ran on this head.

Invariants

  • For any dataset and reform, outputs under 3.32.12 equal outputs under 3.32.9 bit for bit, provided no code writes in place into a cached array after branching. Checked differentially on the Enhanced FRS above, across six modes and six years. CI cannot run this check, because it installs one core.
  • The precondition is enforced in CI by the static scan (Copy cached arrays before writing in scenario modifiers #1988) and by the read-only run-time cases, which now also cover the marginal rate, labour supply and capital gains response 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 3 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>

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