Skip to content

Count only supplied inputs that are still stored in their tier - #2308

Open
MaxGhenis wants to merge 30 commits into
mainfrom
fix-supplied-input-tiers
Open

MaxGhenis wants to merge 30 commits into
mainfrom
fix-supplied-input-tiers

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2307
Reproducer: policyengine_uk/tests/test_supplied_inputs.py::test_a_value_cached_over_an_input_is_not_supplied
axiom: n/a: simulation infrastructure, no policy rule change

Stacked on #2265. #2265 adds stored_supplied_value, and this PR builds on it rather than adding a second copy. This branch contains #2265's head, e804e8bc, and targets main, so CI runs on it. Land #2265 first: this PR's diff then shows only its own three files.

This branch merges main at 47a2817 (merge commit 3bce60e). main had added model values (set_model_value), copied populations and 38 more variables in SUPPLIED_INPUT_VARIABLES, and requires policyengine-core 3.33.0 or later (uv.lock pins 3.34.1).

0e44f0988 merges e804e8bc into 71fb10ca, with two conflicts. In simulation.py both sides added the subsample_simulation import, and the merge keeps one copy. In utils/supplied_inputs.py the module docstring keeps this PR's paragraph on tiers, which covers every helper, and #2265's sentence on model values in supplied_input_arrays. The merge also keeps this PR's _supplied_without_reading and #2265's is_model_value.

What was wrong

supplied_input and supplied_input_periods treated a value cached over a supplied input as supplied. #2307 reproduces it: after set_input of 100 and put_in_cache of 999 for the same period, supplied_input returned [999.] and supplied_input_periods returned [2025], while core's Holder._stores_user_input returned False. The same script on main at 47a2817 with policyengine-core 3.34.1 still prints [999.] and [2025], and core's Simulation._get_recorded_input_array returns None.

Why

Both helpers checked only two things: that core recorded the input (Simulation._user_input_keys), and that Holder._get_array_from_storage returned something. That method reads memory first, then disk, and does not ask whether the value it finds is the input. The record outlives a value cached over the input. put_in_cache stores over the input and takes that tier off the holder's record of where the input is (Holder._user_input_storage), but the _user_input_keys entry stays. So there were two wrong answers:

  1. A value cached over the input in the same tier came back as supplied.
  2. For an input stored on disk, with a value cached over it in memory, the helpers read memory and returned the cached value.

What changes

  • supplied_input reads through stored_supplied_value (from Give UK simulations a working subsample #2265). That reads a supplied value only from a tier in which core's Holder._stores_user_input(period, branch_name, tier) finds the input: a tier that the holder records the input in and that holds a value the engine did not calculate. Memory comes before disk.
  • supplied_input_periods uses the same rule. It finds the tier without reading the value, which may be on disk. It reads the value only when the model stored one for the period (set_model_value), to tell the model's value from a supplied one, as supplied_input does. stored_supplied_value and the periods helper share a private _supplied_value_storage. What stored_supplied_value returns stays the same.
  • Unchanged:
    • Branch order. supplied_input still looks at this branch first, then each branch it was made from, then the default branch. If a branch's input has gone, it reads the next one, as core's _get_recorded_input_array does.
    • Model values. Neither helper returns a value the model stored with set_model_value while it stays the stored value.
    • Neutralised variables. A neutralised variable whose input survives reads as its default.
    • SUPPLIED_INPUT_VARIABLES and its check.
  • Not changed: drop_missing_supplied_inputs. It forgets a record whose value has gone from both tiers, the population-rebuild case (policyengine-core#605). It keeps a record whose input a cached value has replaced. That does no harm now: the helpers ignore such a record, and so does core's input export (_stores_user_input).

Does this change results?

Not in normal calculation. The defect shows only when code stores a value with Holder.put_in_cache and its default derived=False, or with Holder._set.

  • In policyengine-core 3.34.1, Simulation calls put_in_cache once, for each value it calculates, with derived=True.
  • put_in_cache with derived=True stores nothing when the value get_array would read for that period is an input.
  • Core's other writers store inputs: set_input and its period helpers, and the input replay in _invalidate_all_caches.
  • The simulation dumper restores a dump into a new simulation, one value for each variable and period, so it stores nothing over an input either.
  • policyengine-uk code outside its tests calls neither put_in_cache nor Holder._set.
  • A code search of the PolicyEngine organisation found direct put_in_cache calls only in US code: one crfb-tob-impacts worker and two policyengine-us tests.

test_calculating_leaves_every_supplied_value_supplied executes this claim. It supplies all 45 variables in SUPPLIED_INPUT_VARIABLES for two years, each with a value of its own type for its own period, on the default branch, and one more input on a branch. Then it calculates ten outputs over five years on both, and checks that each input still counts as supplied and keeps its value, and that core still counts every recorded input as stored. This test also passes on the code before the fix.

The callers that read through the helpers, such as the bus fare, NI class 4 loss, carer benefit, childcare, Housing Benefit, PIP and energy price guarantee formulas, get a different answer only after one of the routes above has cached a value over the input.

Invariants

policyengine_uk/tests/test_supplied_inputs.py states them. The model covers every sequence of these steps: inputs set, values cached (calculated or not), arrays deleted and calculations run. Each step can act on a branch, on the branch it was made from or on the default branch, in memory or on disk.

  1. Survival (model). supplied_input returns the input on the nearest branch that still stores one for the period, reading memory before disk. Otherwise it returns None. An input survives until something deletes it, or until another input or a cached value replaces it in the same tier. A value cached in memory over an input on disk leaves that input. A calculated value never replaces an input.
  2. Agreement. supplied_input returns a value for a period exactly when supplied_input_periods lists that period, and the list runs from the earliest period.
  3. Differential.
    • For every recorded input, the helpers count it as supplied exactly when core's Holder._stores_user_input does.
    • On core 3.33.1 and later, supplied_input returns what Simulation._get_recorded_input_array returns.
    • On core 3.33.2 and later, supplied_input_periods lists the periods that Simulation._get_set_input_periods lists.
  4. Calculation. After calculation, every supplied value of every registered variable still counts as supplied and keeps its value.

Intended: a neutralised variable whose input survives reads as its default.

Tests

  • policyengine_uk/tests/test_supplied_inputs.py has 53 tests:
    • one regression test for each of the 45 registered variables, with a value of the variable's own type (float, integer, boolean or enum) for its own period (a year, or a month for the four monthly variables);
    • other years' inputs still count;
    • an input on disk under a value cached in memory counts as supplied;
    • a value cached over an input on disk does not;
    • the engine never stores a calculated value over an input;
    • branch order;
    • a neutralised input;
    • the calculation test above;
    • a Hypothesis property test (100 examples, five explicit ones) of a model over a three-branch chain and both tiers.
  • Hypothesis found an error in the model, not in the fix. From core 3.33, set_input first drops cached values that may depend on the input, so an input can go to disk where the model expected memory. The model now records the tier that holds each value, and an explicit example pins the case.
  • Run locally at 71fb10c with policyengine-core 3.34.1 (uv sync --frozen --extra dev), -n 4 and a fresh --basetemp: this PR's test files and every test file that imports the helpers gave 590 passed. The 16 files: test_supplied_inputs.py, test_subsample.py, test_bus_fare_reforms.py, test_input_records.py, test_ni_class_4_profits_properties.py, test_gift_aid.py, test_housing_benefit_accommodation_dates.py, test_input_dataframe_export.py, test_capital_gains_projection.py, test_household_subset.py, aggregate_impact/test_aggregate_impact_childcare_provenance.py, aggregate_impact/test_aggregate_impact_guard.py, test_trading_allowance_claim_status_data_year.py, test_childcare_birthday_terms.py, test_benefit_cap_specified_benefits_properties.py and test_current_education_order.py.
  • YAML policy tests at 71fb10c with core 3.34.1: the 250 files under policyengine_uk/tests/policy that name a variable in SUPPLIED_INPUT_VARIABLES gave 3,420 passed. They include bus_journeys, person_bus_fare_spending, loss_relief, ni_class_2 and ni_class_4_profits (68 passed on their own).
  • policyengine_uk/tests/code_health at 71fb10c: 3,163 passed.
  • Mutation check at 71fb10c, core 3.34.1, against test_supplied_inputs.py:
    • The helpers as they stood before the fix (reading through Holder._get_array_from_storage, with main's model-value check kept): 51 of 53 fail. The two that pass, test_a_calculated_value_is_never_stored_over_an_input and test_calculating_leaves_every_supplied_value_supplied, should pass before the fix too.
    • The tests catch each of six mutants of the fix:
      • no fall-back to the parent branch: 2 fail;
      • the periods helper left on the old rule: 50 fail;
      • ignoring the recorded tiers: 51 fail;
      • reading disk before memory: 1 fails (the Hypothesis test);
      • the periods helper ignoring model values: 2 fail; test_input_records.py, run alongside, catches none. A default single-person simulation stores model values of uc_LCWRA_element from 2026, and the mutant lists those years as supplied.
  • uvx ruff@0.17.2 format --check ., uvx ruff@0.17.2 check ., uvx ruff@0.17.2 check --select F811,F821 policyengine_uk and git diff --check pass at 71fb10c.
  • Not run locally: the full suite, or any test at 0e44f0988. CI runs the suite there.
  • CI's aggregate impact report at 0e44f0988, merged into main (040e9a5f07 against 6fe91ff6e8, enhanced_frs_2024_25.h5, policyengine-uk-data 1.58.1, fiscal years 2025-26 to 2031-32), breaks no check and flags no moves, and no household's net income changes in any year. One check, sign:vat, fails on both sides, as it does on main.

🤖 Generated with Claude Code

MaxGhenis and others added 11 commits October 10, 2026 15:36
Simulation.subsample was inherited from policyengine-core and raised
TypeError: it rebuilds through build_from_dataset() with no arguments,
and the UK builder takes the dataset to load.

Making that argument optional would not have been enough. Core's method
reads each person's household and benefit unit back from the stored
person_household_id and person_benunit_id inputs, which the UK model
holds as 32-bit floats, so identifiers above 2**24 would merge
households. It also leaves Simulation.dataset as a core Dataset, while
UK formulas read dataset.years, and it rebuilds a baseline branch, while
a UK baseline is a simulation of its own.

The UK method takes households, benefit units and people from the
populations, keeps every supplied input for the sampled records,
rescales each supplied weight to keep its total, and subsamples the
baseline and the dataset with the same draw.

Fixes #2254

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A result calculated before subsampling is calculated again on the sample,
and the record of an input that a calculated value replaced is not kept.
Found by mutating the subsample module: removing the cache invalidation
went unnoticed until these assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulations usage page gains a short section on subsampling, since
the method now works for UK simulations. A simulation built from a
DataFrame keeps no dataset object, so test that subsampling it leaves
dataset as None and still cuts the baseline to the same households.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI formats Python blocks in Markdown with the latest ruff, which the
locked ruff does not check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address the independent review of #2265:

- Subsampling a clone changed the source simulation's baseline, because
  policyengine-core's clone shares a separate baseline. Replace each
  baseline with a subsampled copy instead of changing it in place.
- A calculated value cached in memory over an input stored on disk was
  kept as the input. Read each supplied input from the tier the holder
  records it in, as Holder._stores_user_input decides.
- A trace kept values calculated on the replaced populations. Give each
  simulation a new tracer, shared as before.
- Say that dataset holds the rows of the dataset the simulation was
  built from, without later set_input values, and that weights are
  unbiased in the sampling period only. Generate multi-year datasets
  whose weights change differently by household in the property test.

Also adapt to main: a DataFrame-built simulation now has a dataset, and
test memberships without assuming how the identifiers are stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address the second independent review of #2265:

- Copying a baseline with clone copied every cached array before the
  subsample discarded them, which took a full-size run past a 20 GB
  memory limit. Announce the copy as get_branch does, so it shares the
  arrays as views and copies none.
- A negative household weight could leave a negative weight in the
  sample. Refuse negative sampling weights, and negative values in any
  weight being rescaled.
- An input for part of a year, which a DataFrame can give, stays in the
  simulation and out of the dataset of whole years. Say so, and test it.
- Give the property test two explicit cases whose weight shares change
  from year to year, which the generated cases had not reached.
- Qualify the docstring's sampling and unbiasedness claims by
  quantize_weights, and describe the first-weights fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation testing showed the rescaling check alone also caught a negative
household weight, so removing the draw's own check went unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
supplied_input and supplied_input_periods checked only that core recorded
the input and that Holder._get_array_from_storage returned something. That
reads memory before disk and does not ask whether the value is the input,
so a value cached over a supplied input counted as supplied, and an input
on disk under a value cached in memory was read from memory.

Both now use the tier rule of stored_supplied_value (Holder's
_stores_user_input), keep the nearest-branch-first order, and keep the
neutralised-variable behaviour. Fixes #2307.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Example tests for a value cached over an input in memory and on disk, an
input on disk under a value cached in memory, branch precedence and a
neutralised variable. A Hypothesis model of inputs set, values cached,
arrays deleted and calculations run over a three-branch chain and both
tiers checks the helpers against the model, against each other, and
against policyengine-core's Holder._stores_user_input and (from 3.33.1)
Simulation._get_recorded_input_array. A last test calculates with every
registered variable supplied and checks each input is still supplied, so
normal calculation never hit the defect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On policyengine-core 3.33.2 and later, the property test also checks that
the periods the helper lists are the periods core lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 10 commits October 21, 2026 21:45
Main added a record of the model's own values (set_model_value), which
the UK Universal Credit health element reform stores in every simulation,
and a record of where each capital gains input came from. A subsample
rebuilds the populations, which forgets both, so it now stores each
model value back with set_model_value, restores the capital gains
record, and carries the dataset's projection annotation. Resolved the
supplied_inputs.py conflict by keeping both sides' helpers, and the
simulation.py import conflict by importing both modules.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address vahid-ahmadi's review of #2265 (points 3 and 4):

- With quantize_weights, a drawn record whose own weight is zero got a
  positive weight: a household drawn by its weight in the sampling year
  with a zero weight in another year, or a zero person or benefit unit
  weight. A zero weight stands for no one, so it now stays zero, and the
  year's total goes to the drawn records that stand for someone.
- Each supplied weight is rescaled to its own entity's total, so a person
  weight supplied alongside household weights need not equal the new
  weight of the person's household. Say so where the weights are listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d has no weight

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@juaristi22 juaristi22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5b4dc2143347b49226d06fc9e6421495da4e5cfe, using the stacked merge-base diff over #2265. No actionable findings in surviving-input storage tiers or affected consumers.

Both helpers use the same eligibility predicate as Core’s input-store checks, including recorded-empty locations, derived values, memory/disk tier order and overwritten child-branch inputs. The unchanged subsampling API retains its behavior.

Validation: all 104 tests in the five affected files passed, including the three-branch/two-tier properties, ordinary household net income and income tax, bus fare and NI loss consumers, and subsample rebuilds. Ruff formatting passed. Execution used Core3.33.2; I also compared the identical predicate in the pinned Core3.32.27 source. The documented #2265 merge dependency remains.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


Generated by Claude Code

Conflicts:
- simulation.py: keep both imports (measurement_branches from main,
  subsample from this branch).
- utils/supplied_inputs.py: keep main's wording for what a rebuild
  forgets and this branch's paragraph on supplied_input_arrays.
- tests/test_input_records.py: keep main's rebuilt-population tests and
  this branch's restoring-reform test; join the imports and use main's
  `period` import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6HhDW9nQVQhdZKvrUS
main records each dataset year's ISA income contract and the series its
savings income grows by on the simulation and the dataset. A rebuild of
the populations resets the simulation's copies, and the subsampled
dataset was built without them, so a legacy-contract dataset projected
savings interest past its last year differently after a subsample.
Restore both records after the rebuild, alongside the capital gains
origins, and carry each year's energy price basis, ISA income contract
and savings income series to the subsampled dataset.

Also reword the subsample module docstring for integer membership ids,
and rename the changelog fragment to 2254.fixed.md without list markers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6HhDW9nQVQhdZKvrUS
Removing the restore of the defaulted capital gains periods after a
subsample changed the reported origin of a neutralised input from
default to projected, and no test caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6HhDW9nQVQhdZKvrUS
Resolve the conflicts in policyengine_uk/simulation.py (keep both sides'
imports) and policyengine_uk/utils/supplied_inputs.py: keep main's
model-value records (set_model_value) and population copying, and this
branch's tier rule. supplied_input reads through stored_supplied_value and
then leaves out a model value; supplied_input_periods finds the tier
without reading the value, and reads it only to tell a model value apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FT2A5BnVxvVkQuCTo
policyengine-core 3.33.0, which main now requires, checks a single tier
with _stores_user_input(period, branch_name, storage_name). Use it instead
of this module's copy of the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FT2A5BnVxvVkQuCTo
Main registers 45 variables in SUPPLIED_INPUT_VARIABLES, among them
booleans, enums, integers and monthly variables. Store a value of each
variable's own type for its own period, rather than a float for a year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6FT2A5BnVxvVkQuCTo
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_TITLE.txt

Count only supplied inputs that are still stored in their tier

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

PR_BODY.md

Fixes #2307
Reproducer: policyengine_uk/tests/test_supplied_inputs.py::test_a_value_cached_over_an_input_is_not_supplied
axiom: n/a: simulation infrastructure, no policy rule change

**Stacked on #2265.** #2265 adds `stored_supplied_value`, and this PR builds on it rather than adding a second copy. The PR targets #2265's branch (`fix-subsample-2254`). CI runs only for PRs into `main`, so it runs once GitHub moves this PR to `main`.

This branch merges `main` at 47a2817e2d57e6acbc08b76e09fde89fc27b81fa (merge commit 3bce60ef4c5e153a7c95a51341b0e3a85b8644f7). `main` had added model values (`set_model_value`), copied populations and 38 more variables in `SUPPLIED_INPUT_VARIABLES`, and requires policyengine-core 3.33.0 or later (`uv.lock` pins 3.34.1).

## What was wrong

`supplied_input` and `supplied_input_periods` treated a value cached over a supplied input as supplied. #2307 reproduces it: after `set_input` of 100 and `put_in_cache` of 999 for the same period, `supplied_input` returned `[999.]` and `supplied_input_periods` returned `[2025]`, while core's `Holder._stores_user_input` returned `False`. The same script on `main` at 47a2817e with policyengine-core 3.34.1 still prints `[999.]` and `[2025]`, and core's `Simulation._get_recorded_input_array` returns `None`.

## Why

Both helpers checked only two things: that core recorded the input (`Simulation._user_input_keys`), and that `Holder._get_array_from_storage` returned something. That method reads memory first, then disk, and does not ask whether the value it finds is the input. The record outlives a value cached over the input. `put_in_cache` stores over the input and takes that tier off the holder's record of where the input is (`Holder._user_input_storage`), but the `_user_input_keys` entry stays. So there were two wrong answers:

1. A value cached over the input in the same tier came back as supplied.
2. For an input stored on disk, with a value cached over it in memory, the helpers read memory and returned the cached value.

## What changes

- `supplied_input` reads through `stored_supplied_value` (from #2265). That reads a supplied value only from a tier in which core's `Holder._stores_user_input(period, branch_name, tier)` finds the input: a tier that the holder records the input in and that holds a value the engine did not calculate. Memory comes before disk.
- `supplied_input_periods` uses the same rule. It finds the tier without reading the value, which may be on disk. It reads the value only when the model stored one for the period (`set_model_value`), to tell the model's value from a supplied one, as `supplied_input` does. `stored_supplied_value` and the periods helper share a private `_supplied_value_storage`. What `stored_supplied_value` returns stays the same.
- Unchanged:
  - Branch order. `supplied_input` still looks at this branch first, then each branch it was made from, then the default branch. If a branch's input has gone, it reads the next one, as core's `_get_recorded_input_array` does.
  - Model values. Neither helper returns a value the model stored with `set_model_value` while it stays the stored value.
  - Neutralised variables. A neutralised variable whose input survives reads as its default.
  - `SUPPLIED_INPUT_VARIABLES` and its check.
- Not changed: `drop_missing_supplied_inputs`. It forgets a record whose value has gone from both tiers, the population-rebuild case (policyengine-core#605). It keeps a record whose input a cached value has replaced. That does no harm now: the helpers ignore such a record, and so does core's input export (`_stores_user_input`).

## Does this change results?

Not in normal calculation. The defect shows only when code stores a value with `Holder.put_in_cache` and its default `derived=False`, or with `Holder._set`.

- In policyengine-core 3.34.1, `Simulation` calls `put_in_cache` once, for each value it calculates, with `derived=True`.
- `put_in_cache` with `derived=True` stores nothing when the value `get_array` would read for that period is an input.
- Core's other writers store inputs: `set_input` and its period helpers, and the input replay in `_invalidate_all_caches`.
- The simulation dumper restores a dump into a new simulation, one value for each variable and period, so it stores nothing over an input either.
- policyengine-uk code outside its tests calls neither `put_in_cache` nor `Holder._set`.
- A code search of the PolicyEngine organisation found direct `put_in_cache` calls only in US code: one crfb-tob-impacts worker and two policyengine-us tests.

`test_calculating_leaves_every_supplied_value_supplied` executes this claim. It supplies all 45 variables in `SUPPLIED_INPUT_VARIABLES` for two years, each with a value of its own type for its own period, on the default branch, and one more input on a branch. Then it calculates ten outputs over five years on both, and checks that each input still counts as supplied and keeps its value, and that core still counts every recorded input as stored. This test also passes on the code before the fix.

The callers that read through the helpers, such as the bus fare, NI class 4 loss, carer benefit, childcare, Housing Benefit, PIP and energy price guarantee formulas, get a different answer only after one of the routes above has cached a value over the input.

## Invariants

`policyengine_uk/tests/test_supplied_inputs.py` states them. The model covers every sequence of these steps: inputs set, values cached (calculated or not), arrays deleted and calculations run. Each step can act on a branch, on the branch it was made from or on the default branch, in memory or on disk.

1. **Survival (model).** `supplied_input` returns the input on the nearest branch that still stores one for the period, reading memory before disk. Otherwise it returns `None`. An input survives until something deletes it, or until another input or a cached value replaces it in the same tier. A value cached in memory over an input on disk leaves that input. A calculated value never replaces an input.
2. **Agreement.** `supplied_input` returns a value for a period exactly when `supplied_input_periods` lists that period, and the list runs from the earliest period.
3. **Differential.**
   - For every recorded input, the helpers count it as supplied exactly when core's `Holder._stores_user_input` does.
   - On core 3.33.1 and later, `supplied_input` returns what `Simulation._get_recorded_input_array` returns.
   - On core 3.33.2 and later, `supplied_input_periods` lists the periods that `Simulation._get_set_input_periods` lists.
4. **Calculation.** After calculation, every supplied value of every registered variable still counts as supplied and keeps its value.

Intended: a neutralised variable whose input survives reads as its default.

## Tests

- `policyengine_uk/tests/test_supplied_inputs.py` has 53 tests:
  - one regression test for each of the 45 registered variables, with a value of the variable's own type (float, integer, boolean or enum) for its own period (a year, or a month for the four monthly variables);
  - other years' inputs still count;
  - an input on disk under a value cached in memory counts as supplied;
  - a value cached over an input on disk does not;
  - the engine never stores a calculated value over an input;
  - branch order;
  - a neutralised input;
  - the calculation test above;
  - a Hypothesis property test (100 examples, five explicit ones) of a model over a three-branch chain and both tiers.
- Hypothesis found an error in the model, not in the fix. From core 3.33, `set_input` first drops cached values that may depend on the input, so an input can go to disk where the model expected memory. The model now records the tier that holds each value, and an explicit example pins the case.
- Run locally at 71fb10ca1c332341137bce7cafc1f0b46c3d0213 with policyengine-core 3.34.1 (`uv sync --frozen --extra dev`), `-n 4` and a fresh `--basetemp`: this PR's test files and every test file that imports the helpers gave 590 passed. The 16 files: `test_supplied_inputs.py`, `test_subsample.py`, `test_bus_fare_reforms.py`, `test_input_records.py`, `test_ni_class_4_profits_properties.py`, `test_gift_aid.py`, `test_housing_benefit_accommodation_dates.py`, `test_input_dataframe_export.py`, `test_capital_gains_projection.py`, `test_household_subset.py`, `aggregate_impact/test_aggregate_impact_childcare_provenance.py`, `aggregate_impact/test_aggregate_impact_guard.py`, `test_trading_allowance_claim_status_data_year.py`, `test_childcare_birthday_terms.py`, `test_benefit_cap_specified_benefits_properties.py` and `test_current_education_order.py`.
- YAML policy tests at 71fb10ca with core 3.34.1: the 250 files under `policyengine_uk/tests/policy` that name a variable in `SUPPLIED_INPUT_VARIABLES` gave 3,420 passed. They include `bus_journeys`, `person_bus_fare_spending`, `loss_relief`, `ni_class_2` and `ni_class_4_profits` (68 passed on their own).
- `policyengine_uk/tests/code_health` at 71fb10ca: 3,163 passed.
- Mutation check at 71fb10ca, core 3.34.1, against `test_supplied_inputs.py`:
  - The helpers as they stood before the fix (reading through `Holder._get_array_from_storage`, with `main`'s model-value check kept): 51 of 53 fail. The two that pass, `test_a_calculated_value_is_never_stored_over_an_input` and `test_calculating_leaves_every_supplied_value_supplied`, should pass before the fix too.
  - The tests catch each of six mutants of the fix:
    - no fall-back to the parent branch: 2 fail;
    - the periods helper left on the old rule: 50 fail;
    - ignoring the recorded tiers: 51 fail;
    - reading disk before memory: 1 fails (the Hypothesis test);
    - the periods helper ignoring model values: 2 fail; `test_input_records.py`, run alongside, catches none. A default single-person simulation stores model values of `uc_LCWRA_element` from 2026, and the mutant lists those years as supplied.
- `uvx ruff@0.17.2 format --check .`, `uvx ruff@0.17.2 check .`, `uvx ruff@0.17.2 check --select F811,F821 policyengine_uk` and `git diff --check` pass at 71fb10ca.
- Not run locally: the full suite. CI runs it once this PR moves to `main`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Ready for hub review

Head: 71fb10ca1c332341137bce7cafc1f0b46c3d0213 on claude/merge-main-pr-2308-4p6qfw. It branches from this PR's head 5b4dc2143347b49226d06fc9e6421495da4e5cfe. The harness pins pushes to claude/..., so I did not push to fix-supplied-input-tiers. No PR has this branch as its head, so no CI has run.

Commits on top of 5b4dc21:

  1. 3bce60ef merges main at 47a2817e2d57e6acbc08b76e09fde89fc27b81fa. main had moved past the brief's 95dfc62f only by Count Maternity Allowance in the benefit cap #2811 (Maternity Allowance in the benefit cap). That change touches neither conflicted file, and git merge-tree gave the same two conflicts against both commits.
  2. 42414bd5 asks core's Holder._stores_user_input which tier holds the input.
  3. e02d116c generalises the tests to main's 45 registered variables.
  4. 71fb10ca renames the changelog fragment to changelog.d/2307.fixed.md and drops its leading list marker.

Open findings

None. The PR has one review, 5638137428 by juaristi22 at 5b4dc21: APPROVED, "No actionable findings". It has no inline review comments and no issue comments besides my pick-up note. The approval predates the merge.

Conflict resolutions

  • policyengine_uk/simulation.py:85-93: an import-block conflict only. I kept this branch's subsample_simulation import (Give UK simulations a working subsample #2265) and main's built_from_data, input_dataframe and measurement_branches imports. Git merged the rest of the file cleanly.
  • policyengine_uk/utils/supplied_inputs.py:
    • Module docstring (lines 1-44): I kept main's paragraphs on rebuilds (copy_other_populations, keep_population_records) and model values (set_model_value), this branch's paragraph on tiers, and the supplied_input_arrays line from Give UK simulations a working subsample #2265.
    • supplied_input_periods (around line 160): main read the value twice through _get_array_from_storage to exclude model values. This branch checks the tier without reading. Resolution: a new _supplied_without_reading (around line 305) finds the tier through _supplied_value_storage and reads the value only when _model_values holds a value for that key, then applies main's _is_model_value. This keeps both intents.
    • supplied_input (around line 230): Git merged it cleanly. It reads through stored_supplied_value, then main's model-value check returns None, then the neutralised default.
    • Tail of the file: I kept Give UK simulations a working subsample #2265's supplied_input_arrays and stored_supplied_value, this PR's _supplied_value_storage, and all of main's model-value and population-copy helpers.
  • Where main now does what the PR did: main requires policyengine-core 3.33.0 or later. From that version, Holder._stores_user_input(period, branch_name, storage_name) checks one tier. I diffed the 3.33.0 and 3.34.1 sources: it and holders.helpers._is_input and get_stored_input match. So commit 42414bd5 drops this PR's copy of the per-tier rule from _supplied_value_storage (supplied_inputs.py:291-303) and calls core's method. I kept stored_supplied_value rather than switching to core's get_stored_input. Core's helper differs in three ways: it requires the key in _user_input_keys, it counts any stored value as an input when the simulation has no record, and it writes _user_input_storage (_seed_input_storage). subsample.py and the tests call stored_supplied_value directly.
  • main still has the supplied_input and supplied_input_periods count a value cached over a supplied input as supplied #2307 bug. I ran supplied_input and supplied_input_periods count a value cached over a supplied input as supplied #2307's script on main at 47a2817 with core 3.34.1, and it printed [999.] and [Period(2025)], while _get_recorded_input_array returned None. So the PR's fix is still needed.

Tests changed by the merge

main grew SUPPLIED_INPUT_VARIABLES from 7 to 45 variables, among them booleans, enums, integers and four monthly variables. At the merge commit, 14 tests failed:

  • 13 of the parametrised test_a_value_cached_over_an_input_is_not_supplied, which stored a float for a year. Enums raised Unsupported array dtype: float64.
  • test_calculating_leaves_every_supplied_value_supplied, which asserted that a hard-coded table of 7 variables equals the registered set.

Commit e02d116c adds stored_period and value_of_type (test_supplied_inputs.py:101-125), which store a value of each variable's own type for its own period. The calculation test now supplies all 45 variables. The hasattr(..., "_get_recorded_input_array") guards stay: core 3.33.0, which pyproject.toml still allows, lacks that method.

Tests run (all at 71fb10ca, core 3.34.1, uv sync --frozen --extra dev)

  • Python, -n 4, fresh --basetemp: 590 passed across this PR's files and every test file that imports the helpers. The 16 files are test_supplied_inputs.py (53), test_subsample.py, test_bus_fare_reforms.py, test_input_records.py, test_ni_class_4_profits_properties.py, test_gift_aid.py, test_housing_benefit_accommodation_dates.py, test_input_dataframe_export.py, test_capital_gains_projection.py, test_household_subset.py, aggregate_impact/test_aggregate_impact_childcare_provenance.py, aggregate_impact/test_aggregate_impact_guard.py, test_trading_allowance_claim_status_data_year.py, test_childcare_birthday_terms.py, test_benefit_cap_specified_benefits_properties.py and test_current_education_order.py.
  • YAML: 3,420 passed across the 250 policy files that name a registered variable. The five files the PR body names gave 68 passed on their own.
  • policyengine_uk/tests/code_health: 3,163 passed.
  • uvx ruff@0.17.2 format --check ., check . and check --select F811,F821 policyengine_uk: pass. git diff --check origin/main HEAD: clean.
  • .github/check_changelog_fragments.py --base origin/main --head HEAD: OK. It warns only about Give UK simulations a working subsample #2265's fix-subsample-2254.fixed.md list marker, which I left alone.
  • .github/check_fix_pr.py on the final title and body with head 71fb10ca: PASS. It found 2 changed executable tests against main, and 504 against the current fix-subsample-2254.

Mutation and revert checks (repeated because the merge changed the code they cover)

All ran against test_supplied_inputs.py at 71fb10ca:

  • Pre-fix helpers (_get_array_from_storage, keeping main's model-value check): 51 of 53 fail. The two calculation tests pass, as before.
  • No fall-back to the parent branch: 2 fail.
  • The periods helper on the old rule: 50 fail.
  • Ignoring the recorded tiers: 51 fail.
  • Reading disk before memory: 1 fails (the Hypothesis test).
  • New, for the merged code: the periods helper ignoring model values. 2 fail: the uc_LCWRA_element regression case and the calculation test. A default single-person simulation stores model values of uc_LCWRA_element from 2026 onwards. test_input_records.py catches none of these mutants.

Skipped and why

  • The full suite, which CI runs.
  • Core 3.32.27 runs: main now requires 3.33.0 or later.
  • No law needed: this is simulation infrastructure.
  • No survey-data tests.

Judgement calls

  • I merged main at 47a2817e, not the brief's 95dfc62f, because it was current and the extra commits touch neither conflicted file.
  • I left the stack note in the body. Give UK simulations a working subsample #2265 is still open and fix-subsample-2254 is still the base. That base gained four commits after this PR's head (9a91736f "Preserve neutralised inputs when subsampling", 74b0c65d, 1fbfa6c7, e4c54d7f), and this branch does not include them. Its last merge of main (fc537e4e) predates 47a2817e. Against that base, GitHub's diff will show main's changes too, until Give UK simulations a working subsample #2265 merges main or this PR moves to main.
  • I renamed the fragment from fix-supplied-input-tiers.fixed.md to 2307.fixed.md. Towncrier's issue_format is empty, so the text keeps (#2307).

Sentences in PR_BODY.md that carry a CI figure

None. Every count in the body comes from a local run labelled 71fb10ca. The only CI sentences are "CI runs only for PRs into main, so it runs once GitHub moves this PR to main." and "Not run locally: the full suite. CI runs it once this PR moves to main."


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis changed the base branch from fix-subsample-2254 to main November 5, 2026 11:29
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Aggregate impact on certified data: pass

Base 040e9a5f07 against head 6fe91ff6e8, both on enhanced_frs_2024_25.h5 (policyengine-uk-data 1.58.1, sha256 056e80fd8e91), fiscal years 2025-26 to 2031-32. Each year is a full microsimulation run on each side.

Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in report_spec.yaml; a flag does not fail the job).

Workflow run and full report

Notes

  • In the years a figure from fewer than 10 records is suppressed (spending.disabled_students_allowance), every total containing it, and the decile incomes, are rebuilt from their other components, so no published total includes it. Those totals are marked †. A change is withheld where the two sides leave out different figures and can't be lined up.

Moves flagged for review

None.

Spending by benefit and service

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed

Caseloads

Measure 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed

Household net income by decile

No household's net income changes in any year.

No change in any year: receipts by tax head; government balance, each component counted once; model totals; household income totals; population; poverty rates; people in poverty; inequality; mean household net income in each side's own deciles; standard reform costings (change in gov_balance); standard reforms: change in each reform's target.

Checks failing on both base and head (1)
  • VAT excluding private school fees is never negative (sign:vat): 2025-26: 76 units below zero, a weighted £-53,888,684 in total; 2026-27: 76 units below zero, a weighted £-55,777,260 in total; 2027-28: 76 units below zero, a weighted £-57,727,917 in total
Counted-once check

Each of 92 components was raised by £1 per unit in 2026-27, and every head of its totals was read household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A head that breaks a rule is measured again twice: with every other head held at its baseline value, and with every other head held at its value after the first £1 while the component rises again. It fails where either still shows the response. 0 failed; 0 were skipped.

  • widowed_parents_allowance: pension_credit responds only through other heads in fewer than 10 households (other heads that moved there: carers_allowance; gov_spending; not a failure)
  • widowed_parents_allowance: pension_credit responds only through other heads in fewer than 10 households (other heads that moved there: carers_allowance; household_benefits; not a failure)
Provenance
Side Commit Runtime Environment hash Partition hash Time
base main 040e9a5f07 uk 2.175.4 (files 889511591b1d), core 3.34.1, py 3.13.18 aarch64 b1e13a3ca1b7 3bcfb3473960 2448.13s
head #2308 merged into main 6fe91ff6e8 uk 2.175.4 (files 4bf9253e5a62), core 3.34.1, py 3.13.18 aarch64 b1e13a3ca1b7 3bcfb3473960 2512.08s

Dataset: hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@2526edbbb687a3b54dbb11272e5389e4b62d2ac2, sha256 056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784 (verified on both sides), policyengine-uk-data 1.58.1.

Receipts by tax head: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Final charge on cash interest in non-cash ISAs £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Lifetime ISA withdrawal charge £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Income tax £294.46bn £315.23bn £335.82bn £351.28bn £366.96bn £385.36bn £399.99bn
National Insurance (employee and self-employed) £51.70bn £54.14bn £56.13bn £57.73bn £59.76bn £61.64bn £63.51bn
National Insurance (employer) £148.71bn £155.37bn £160.88bn £165.09bn £172.06bn £177.36bn £182.57bn
Employer NI change borne by capital £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Employer NI change borne by consumers £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Capital gains tax £12.60bn £13.15bn £13.60bn £14.11bn £14.67bn £15.24bn £15.80bn
Student loan repayments £7.82bn £8.13bn £8.39bn £8.58bn £8.79bn £9.04bn £9.29bn
VAT excluding private school fees £249.37bn £256.87bn £266.33bn £272.70bn £278.97bn £285.24bn £291.59bn
Change in VAT from its baseline £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
VAT on private school fees £1.95bn £2.01bn £2.09bn £2.14bn £2.20bn £2.26bn £2.31bn
Change in private school VAT from its baseline £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Fuel duties £24.71bn £24.13bn £26.00bn £26.83bn £26.51bn £25.78bn £24.63bn
Alcohol duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Tobacco duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Vehicle excise duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Betting and gaming duties £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
TV licence fee £4.26bn £4.40bn £4.41bn £4.42bn £4.43bn £4.44bn £4.45bn
Council tax £49.21bn £52.16bn £54.89bn £57.95bn £61.16bn £64.47bn £67.91bn
High value council tax surcharge £0.00bn £0.00bn £0.00bn £0.48bn £0.49bn £0.50bn £0.51bn
Northern Ireland domestic rates £0.55bn £0.58bn £0.60bn £0.63bn £0.66bn £0.69bn £0.72bn
Business rates £34.68bn £36.20bn £38.00bn £39.46bn £42.27bn £43.25bn £44.29bn
Stamp duty land tax on the household's own purchases £6.88bn £7.32bn £7.66bn £8.06bn £8.47bn £8.91bn £9.35bn
Stamp duty land tax paid by companies £4.72bn £4.57bn £5.29bn £6.03bn £6.37bn £6.92bn £7.45bn
Land transaction tax (Wales) £0.07bn £0.08bn £0.08bn £0.09bn £0.10bn £0.10bn £0.11bn
Land and buildings transaction tax (Scotland) £0.31bn £0.32bn £0.33bn £0.35bn £0.36bn £0.38bn £0.40bn
Unspecified tax change borne by capital £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Unspecified tax change borne by consumers £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Wealth tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Wealth tax excluding the main residence £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Land value tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Carbon tax £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Spending by benefit and service: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Lifetime ISA bonus £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
State Pension £121.68bn £125.92bn £126.72bn £129.48bn £132.98bn £136.65bn £140.41bn
Pension Credit £6.21bn £6.46bn £6.68bn £6.82bn £6.89bn £7.07bn £7.22bn
Winter Fuel Payment £1.86bn £1.85bn £1.79bn £1.77bn £1.78bn £1.78bn £1.78bn
Pension Age Winter Heating Payment (Scotland) £0.19bn £0.20bn £0.20bn £0.21bn £0.21bn £0.22bn £0.22bn
Universal Credit £75.48bn £81.56bn £84.02bn £86.07bn £87.22bn £88.76bn £89.38bn
Housing Benefit £8.56bn £8.51bn £8.83bn £9.05bn £9.28bn £9.56bn £9.83bn
Council Tax Reduction £2.07bn £2.20bn £2.32bn £2.45bn £2.59bn £2.73bn £2.87bn
Employment and Support Allowance (income-related) £0.79bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Employment and Support Allowance (contribution-based) £5.20bn £5.37bn £5.56bn £5.69bn £5.82bn £5.95bn £6.09bn
Jobseeker's Allowance (income-based) £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Jobseeker's Allowance (contribution-based) £0.29bn £0.30bn £0.31bn £0.31bn £0.32bn £0.33bn £0.34bn
Income Support £0.11bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Working Tax Credit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Child Tax Credit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Incapacity Benefit £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Personal Independence Payment £26.95bn £28.03bn £29.05bn £30.09bn £30.75bn £31.51bn £32.21bn
Disability Living Allowance £7.49bn £7.79bn £8.07bn £8.36bn £8.55bn £8.76bn £8.95bn
Attendance Allowance £8.68bn £9.03bn £9.36bn £9.69bn £9.90bn £10.15bn £10.38bn
Carer's Allowance £4.48bn £4.66bn £4.83bn £5.00bn £5.11bn £5.27bn £5.42bn
Carer Support Payment (Scotland) £0.31bn £0.32bn £0.33bn £0.34bn £0.35bn £0.36bn £0.37bn
Carer's Allowance Supplement (Scotland) £0.00bn £0.04bn £0.04bn £0.05bn £0.05bn £0.05bn £0.05bn
Severe Disablement Allowance £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn £0.03bn
Industrial Injuries Disablement Benefit £0.50bn £0.52bn £0.54bn £0.55bn £0.56bn £0.58bn £0.59bn
Armed Forces Compensation Scheme £0.61bn £0.63bn £0.65bn £0.66bn £0.68bn £0.69bn £0.71bn
War Widow's or Widower's Pension £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Child Benefit £16.19bn £16.82bn £17.39bn £18.00bn £18.39bn £18.84bn £19.25bn
Scottish Child Payment £0.49bn £0.52bn £0.53bn £0.55bn £0.56bn £0.57bn £0.56bn
Two Child Limit Payment (Scotland, counterfactual) £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Healthy Start vouchers £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn £0.05bn
Maternity Allowance £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Statutory Maternity Pay £3.30bn £3.41bn £3.54bn £3.62bn £3.70bn £3.78bn £3.87bn
Statutory Paternity Pay £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Statutory Sick Pay £1.12bn £1.16bn £1.20bn £1.23bn £1.26bn £1.29bn £1.32bn
Sure Start Maternity Grant £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn £0.01bn
Bereavement Support Payment £0.67bn £0.69bn £0.71bn £0.73bn £0.75bn £0.76bn £0.78bn
Widowed Parent's Allowance £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Tax-Free Childcare £0.56bn £0.57bn £0.57bn £0.58bn £0.60bn £0.61bn £0.59bn
Extended childcare entitlement £2.99bn £3.50bn £3.59bn £3.71bn £3.78bn £3.86bn £3.93bn
Universal childcare entitlement £2.06bn £2.22bn £2.29bn £2.37bn £2.43bn £2.48bn £2.53bn
Targeted childcare entitlement £0.50bn £0.51bn £0.52bn £0.54bn £0.55bn £0.57bn £0.56bn
Childcare Offer for Wales £0.26bn £0.28bn £0.28bn £0.29bn £0.30bn £0.31bn £0.32bn
Care to Learn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Childcare Grant £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Parents' Learning Allowance £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn £0.20bn
Adult Dependants' Grant £0.06bn £0.06bn £0.04bn £0.04bn £0.04bn £0.04bn £0.04bn
Travel Grant £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed
16 to 19 Bursary Fund £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
NHS spending £203.89bn £204.30bn £204.56bn £204.91bn £205.34bn £205.85bn £206.35bn
State education spending £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn £0.02bn
Transport subsidy spending £25.05bn £25.10bn £25.14bn £25.18bn £25.23bn £25.30bn £25.36bn
Cost-of-living support payment £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Energy Bills Rebate £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Household Energy Payment £0.00bn £2.11bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Energy Price Guarantee subsidy £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Basic income £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Disability basic income £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Other public spending budget change £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Caseloads: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Lifetime ISA bonus 0k 0k 0k 0k 0k 0k 0k
State Pension 11,264k 11,145k 10,819k 10,700k 10,723k 10,750k 10,776k
Pension Credit 1,489k 1,484k 1,476k 1,454k 1,452k 1,457k 1,466k
Winter Fuel Payment 7,844k 7,783k 7,473k 7,375k 7,391k 7,409k 7,427k
Pension Age Winter Heating Payment (Scotland) 803k 802k 800k 797k 799k 801k 802k
Universal Credit 6,342k 6,418k 6,408k 6,369k 6,363k 6,343k 6,295k
Housing Benefit 1,249k 1,182k 1,181k 1,164k 1,164k 1,166k 1,168k
Council Tax Reduction 3,180k 3,199k 3,205k 3,218k 3,233k 3,244k 3,251k
Employment and Support Allowance (income-related) 71k 0k 0k 0k 0k 0k 0k
Employment and Support Allowance (contribution-based) 719k 720k 721k 722k 724k 726k 728k
Jobseeker's Allowance (income-based) 0k 0k 0k 0k 0k 0k 0k
Jobseeker's Allowance (contribution-based) 67k 67k 67k 67k 67k 68k 68k
Income Support 25k 0k 0k 0k 0k 0k 0k
Working Tax Credit 0k 0k 0k 0k 0k 0k 0k
Child Tax Credit 0k 0k 0k 0k 0k 0k 0k
Incapacity Benefit 0k 0k 0k 0k 0k 0k 0k
Personal Independence Payment 3,567k 3,575k 3,579k 3,585k 3,593k 3,602k 3,611k
Disability Living Allowance 1,291k 1,294k 1,296k 1,298k 1,300k 1,304k 1,307k
Attendance Allowance 1,695k 1,698k 1,700k 1,703k 1,707k 1,711k 1,715k
Carer's Allowance 1,034k 1,037k 1,038k 1,040k 1,042k 1,060k 1,062k
Carer Support Payment (Scotland) 71k 71k 71k 72k 72k 72k 72k
Carer's Allowance Supplement (Scotland) 0k 71k 71k 72k 72k 72k 72k
Severe Disablement Allowance 4k 4k 4k 4k 4k 4k 4k
Industrial Injuries Disablement Benefit 137k 137k 137k 138k 138k 138k 139k
Armed Forces Compensation Scheme 93k 93k 94k 94k 94k 94k 94k
War Widow's or Widower's Pension 0k 0k 0k 0k 0k 0k 0k
Child Benefit 8,123k 8,120k 8,120k 8,130k 8,145k 8,161k 8,178k
Scottish Child Payment 351k 352k 352k 353k 350k 348k 337k
Two Child Limit Payment (Scotland, counterfactual) 0k 0k 0k 0k 0k 0k 0k
Healthy Start vouchers 200k 200k 200k 201k 201k 201k 202k
Maternity Allowance 0k 0k 0k 0k 0k 0k 0k
Statutory Maternity Pay 333k 333k 334k 334k 335k 336k 337k
Statutory Paternity Pay 0k 0k 0k 0k 0k 0k 0k
Statutory Sick Pay 181k 182k 182k 182k 182k 183k 183k
Sure Start Maternity Grant 26k 26k 26k 26k 26k 26k 26k
Bereavement Support Payment 98k 98k 98k 98k 98k 99k 99k
Widowed Parent's Allowance 0k 0k 0k 0k 0k 0k 0k
Tax-Free Childcare 1,043k 1,027k 1,014k 1,009k 1,011k 1,009k 993k
Extended childcare entitlement 795k 800k 796k 797k 798k 798k 792k
Universal childcare entitlement 1,071k 1,072k 1,072k 1,073k 1,076k 1,079k 1,074k
Targeted childcare entitlement 165k 156k 154k 154k 155k 155k 150k
Childcare Offer for Wales 39k 39k 39k 39k 39k 39k 39k
Care to Learn 0k 0k 0k 0k 0k 0k 0k
Childcare Grant 0k 0k 0k 0k 0k 0k 0k
Parents' Learning Allowance 98k 98k 98k 99k 99k 99k 99k
Adult Dependants' Grant 18k 18k 12k 12k 12k 12k 12k
Travel Grant 0k 0k 0k 0k 0k 0k 0k
Disabled Students' Allowance suppressed suppressed suppressed suppressed suppressed suppressed suppressed
16 to 19 Bursary Fund 0k 0k 0k 0k 0k 0k 0k
Cost-of-living support payment 0k 0k 0k 0k 0k 0k 0k
Energy Bills Rebate 0k 0k 0k 0k 0k 0k 0k
Household Energy Payment 0k 21,065k 0k 0k 0k 0k 0k
Energy Price Guarantee subsidy 0k 0k 0k 0k 0k 0k 0k
Basic income 0k 0k 0k 0k 0k 0k 0k
Disability basic income 0k 0k 0k 0k 0k 0k 0k
Government balance, each component counted once: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Government receipts, each component once £892.00bn £934.66bn £980.50bn £1,015.93bn £1,054.24bn £1,091.56bn £1,124.89bn
Government spending £528.93bn † £544.94bn † £549.99bn † £558.68bn † £566.30bn † £575.00bn † £582.59bn †
Government balance, each receipt once £363.07bn † £389.72bn † £430.51bn † £457.24bn † £487.94bn † £516.56bn † £542.31bn †
Benefits in household_benefits but not in gov_spending £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn £0.00bn
Model totals: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
gov_tax £892.00bn £934.66bn £980.50bn £1,015.93bn £1,054.24bn £1,091.56bn £1,124.89bn
household_tax £491.97bn £520.41bn £551.21bn £576.00bn £601.00bn £626.69bn £648.42bn
pre_budget_change_household_tax £484.15bn £512.28bn £542.82bn £566.95bn £591.72bn £617.16bn £638.61bn
gov_spending £528.93bn † £544.94bn † £549.99bn † £558.68bn † £566.30bn † £575.00bn † £582.59bn †
household_benefits £528.93bn † £544.94bn † £549.99bn † £558.68bn † £566.30bn † £575.00bn † £582.59bn †
gov_balance £363.07bn † £389.72bn † £430.51bn † £457.24bn † £487.94bn † £516.56bn † £542.31bn †
Household income totals: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Household market income £1,637.96bn £1,702.77bn £1,757.35bn £1,806.41bn £1,855.97bn £1,912.43bn £1,970.44bn
Household net income £1,636.24bn † £1,687.10bn † £1,714.68bn † £1,746.67bn † £1,777.79bn † £1,816.07bn † £1,858.68bn †
Household net income (HBAI definition) £1,449.13bn £1,499.11bn £1,530.09bn £1,564.35bn £1,596.99bn £1,634.64bn £1,675.60bn
Population: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
People 69,245k 69,383k 69,473k 69,592k 69,738k 69,912k 70,080k
Benefit units 35,110k 35,181k 35,226k 35,286k 35,360k 35,449k 35,534k
Households 31,046k 31,108k 31,149k 31,201k 31,267k 31,345k 31,420k
Poverty rates: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Absolute poverty, before housing costs: everyone 16.3% 15.5% 16.4% 16.3% 16.5% 16.5% 16.6%
Absolute poverty, before housing costs: children 24.7% 22.1% 23.3% 22.9% 23.1% 23.2% 23.5%
Absolute poverty, before housing costs: working-age adults 14.1% 13.8% 14.7% 14.7% 14.8% 14.8% 15.0%
Absolute poverty, before housing costs: pensioners 13.7% 13.4% 14.0% 13.8% 14.0% 14.0% 13.9%
Absolute poverty, after housing costs: everyone 21.6% 21.1% 22.0% 22.6% 22.9% 23.1% 23.5%
Absolute poverty, after housing costs: children 33.3% 31.3% 32.3% 32.5% 32.9% 33.4% 34.2%
Absolute poverty, after housing costs: working-age adults 19.9% 19.8% 20.8% 21.6% 21.9% 22.0% 22.3%
Absolute poverty, after housing costs: pensioners 12.7% 12.4% 13.2% 13.2% 13.3% 13.4% 13.6%
Relative poverty, before housing costs: everyone 17.0% 16.0% 16.4% 16.3% 16.4% 16.3% 16.6%
Relative poverty, before housing costs: children 25.9% 22.9% 23.3% 22.9% 23.0% 22.9% 23.5%
Relative poverty, before housing costs: working-age adults 14.5% 14.1% 14.6% 14.7% 14.7% 14.7% 15.0%
Relative poverty, before housing costs: pensioners 14.5% 14.1% 14.0% 13.8% 13.8% 13.9% 13.9%
Relative poverty, after housing costs: everyone 21.5% 21.0% 21.2% 21.3% 21.3% 21.5% 21.7%
Relative poverty, after housing costs: children 33.1% 31.1% 31.1% 31.0% 31.1% 31.2% 31.8%
Relative poverty, after housing costs: working-age adults 19.9% 19.8% 20.2% 20.4% 20.5% 20.6% 20.8%
Relative poverty, after housing costs: pensioners 12.5% 12.4% 12.2% 11.8% 11.7% 11.8% 12.1%
People in poverty: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Absolute poverty, before housing costs: everyone 11,277k 10,784k 11,408k 11,373k 11,482k 11,521k 11,657k
Absolute poverty, before housing costs: children 3,688k 3,314k 3,496k 3,445k 3,480k 3,502k 3,549k
Absolute poverty, before housing costs: working-age adults 5,965k 5,895k 6,311k 6,366k 6,423k 6,434k 6,524k
Absolute poverty, before housing costs: pensioners 1,624k 1,575k 1,601k 1,562k 1,579k 1,585k 1,584k
Absolute poverty, after housing costs: everyone 14,944k 14,607k 15,318k 15,729k 15,946k 16,150k 16,440k
Absolute poverty, after housing costs: children 4,975k 4,682k 4,837k 4,888k 4,945k 5,040k 5,179k
Absolute poverty, after housing costs: working-age adults 8,463k 8,465k 8,976k 9,348k 9,496k 9,588k 9,715k
Absolute poverty, after housing costs: pensioners 1,506k 1,461k 1,506k 1,494k 1,506k 1,522k 1,546k
Relative poverty, before housing costs: everyone 11,750k 11,088k 11,394k 11,363k 11,422k 11,423k 11,657k
Relative poverty, before housing costs: children 3,872k 3,423k 3,496k 3,443k 3,466k 3,454k 3,549k
Relative poverty, before housing costs: working-age adults 6,159k 6,014k 6,304k 6,361k 6,392k 6,393k 6,524k
Relative poverty, before housing costs: pensioners 1,719k 1,651k 1,594k 1,560k 1,564k 1,576k 1,584k
Relative poverty, after housing costs: everyone 14,864k 14,560k 14,740k 14,811k 14,883k 15,023k 15,241k
Relative poverty, after housing costs: children 4,944k 4,660k 4,660k 4,662k 4,679k 4,707k 4,805k
Relative poverty, after housing costs: working-age adults 8,431k 8,444k 8,687k 8,823k 8,880k 8,974k 9,067k
Relative poverty, after housing costs: pensioners 1,489k 1,456k 1,393k 1,327k 1,325k 1,342k 1,369k
Inequality: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Gini coefficient, equivalised HBAI income before housing costs 0.3078 0.3051 0.3057 0.3056 0.3061 0.3067 0.3083
Mean household net income in each side's own deciles: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Decile 1: mean household_net_income per household £17,681 † £18,010 † £17,918 † £18,160 † £18,301 † £18,547 † £18,732 †
Decile 2: mean household_net_income per household £27,942 † £29,024 † £28,623 † £29,004 † £29,287 † £29,699 † £30,089 †
Decile 3: mean household_net_income per household £34,316 † £35,426 † £36,075 † £36,523 † £36,995 † £37,682 † £38,154 †
Decile 4: mean household_net_income per household £38,934 † £40,687 † £41,716 † £42,770 † £43,423 † £44,111 † £44,446 †
Decile 5: mean household_net_income per household £43,235 † £45,555 † £46,034 † £46,930 † £47,714 † £48,578 † £50,274 †
Decile 6: mean household_net_income per household £47,931 † £49,240 † £50,932 † £51,984 † £52,963 † £53,976 † £54,714 †
Decile 7: mean household_net_income per household £55,659 † £56,577 † £57,445 † £57,649 † £58,750 † £59,668 † £61,096 †
Decile 8: mean household_net_income per household £68,784 † £70,805 † £71,866 † £73,514 † £73,888 † £75,324 † £77,180 †
Decile 9: mean household_net_income per household £78,889 † £81,012 † £80,859 † £82,645 † £85,463 † £86,945 † £89,508 †
Decile 10: mean household_net_income per household £116,861 † £120,160 † £124,545 † £126,419 † £125,752 † £128,367 † £130,693 †
Standard reform costings (change in gov_balance): head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp £6.41bn £6.66bn £6.63bn £6.85bn £7.03bn £7.25bn £7.49bn
Raise higher rate by 1pp £3.61bn £3.87bn £4.00bn £4.22bn £4.44bn £4.71bn £4.83bn
Raise personal allowance by ~800GBP/year £-3.81bn £-3.93bn £-3.99bn £-4.05bn £-4.11bn £-4.24bn £-1.95bn
Raise child benefit by 25GBP/week per additional child £-1.99bn £-1.75bn £-1.55bn £-1.38bn £-1.30bn £-1.18bn £-1.07bn
Reduce Universal Credit taper rate to 20% £-20.77bn £-21.32bn £-21.75bn £-22.00bn £-22.35bn £-22.75bn £-22.94bn
Raise Class 1 main employee NICs rate to 10% £10.89bn £11.30bn £11.71bn £12.01bn £12.31bn £12.62bn £13.00bn
Raise VAT standard rate by 2pp £24.89bn £25.71bn £26.63bn £27.24bn £27.87bn £28.50bn £29.14bn
Raise additional rate by 3pp £2.90bn £3.15bn £3.22bn £3.41bn £3.61bn £3.85bn £4.14bn
Raise the Universal Credit standard allowance by 10% £-3.06bn £-3.22bn £-3.30bn £-3.39bn £-3.48bn £-3.53bn £-3.54bn
Raise the State Pension by 10% £-7.76bn £-7.99bn £-8.22bn £-8.44bn £-8.79bn £-8.52bn £-8.86bn
Standard reforms: change in each reform's target: head levels
Measure (head) 2025-26 2026-27 2027-28 2028-29 2029-30 2030-31 2031-32
Raise basic rate by 1pp: change in income_tax £6.55bn £6.86bn £6.79bn £7.00bn £7.20bn £7.44bn £7.66bn
Raise higher rate by 1pp: change in income_tax £3.61bn £3.92bn £4.01bn £4.23bn £4.45bn £4.72bn £4.84bn
Raise personal allowance by ~800GBP/year: change in income_tax £-3.93bn £-4.07bn £-4.13bn £-4.18bn £-4.22bn £-4.40bn £-1.75bn
Raise child benefit by 25GBP/week per additional child: change in child_benefit £2.33bn £2.14bn £1.94bn £1.76bn £1.65bn £1.52bn £1.40bn
Reduce Universal Credit taper rate to 20%: change in universal_credit £20.74bn £21.31bn £21.79bn £22.05bn £22.37bn £22.78bn £22.99bn
Raise Class 1 main employee NICs rate to 10%: change in national_insurance £11.15bn £11.63bn £12.00bn £12.29bn £12.61bn £12.94bn £13.31bn
Raise VAT standard rate by 2pp: change in vat £24.69bn £25.51bn £26.42bn £27.03bn £27.65bn £28.28bn £28.91bn
Raise additional rate by 3pp: change in income_tax £2.90bn £3.15bn £3.22bn £3.42bn £3.62bn £3.86bn £4.12bn
Raise the Universal Credit standard allowance by 10%: change in universal_credit £3.06bn £3.22bn £3.32bn £3.41bn £3.47bn £3.53bn £3.55bn
Raise the State Pension by 10%: change in state_pension £10.68bn £11.17bn £11.28bn £11.60bn £12.03bn £12.46bn £12.96bn

@MaxGhenis
MaxGhenis marked this pull request as ready for review November 5, 2026 13:10
@MaxGhenis
MaxGhenis requested a review from juaristi22 November 5, 2026 13:10

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.

supplied_input and supplied_input_periods count a value cached over a supplied input as supplied

3 participants