Skip to content

Pilot (not merged): bind the SPI pension band rows at 2023-24 (option 1) - #1413

Closed
MaxGhenis wants to merge 12 commits into
mainfrom
spi-dated-pension-bands
Closed

MaxGhenis wants to merge 12 commits into
mainfrom
spi-dated-pension-bands

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Pilot of option 1 of the UK pensioner calibration review (approved by Max, 2026-10-27). Result: the pilot does not win, so this PR should not merge. It fits the four rows it binds, but on every out-of-sample test it is worse than main and further from the re-index that #1414 merged for #1069. Closed unmerged on that result; the branch is kept, so reopen it if Max rules to adopt option 1 (decision d2111).

A/B result (aggregates only; full tables in docs/evidence/uk-spi-dated-bands/README.md)

Three national builds on one FRS 2024-25 spine, weights solved at 2025 over 1,273 targets: baseline (main 7d45c0c), pilot (this branch plus a "PILOT BUILD ONLY" hold fill, local 71856c6fa), and #1414's candidate.

Test Baseline Pilot #1414 candidate
Taxpaying State Pension recipients, 2025-26 (HMRC implied 8.65m–8.81m) 8.84m 9.26m (+5.1% above) 8.74m (inside)
Non-taxpaying recipients (HMRC implied 3.58m–3.74m) 3.53m 3.13m (−12.6% below) 3.63m (inside)
ITL Table 2.1, UK taxpayers at State Pension age (9.005m) −0.81% +3.92% not run
ITL Table 2.2 residents at State Pension age / 65+ (5% tolerance) +1.03% / +1.28% +5.84% / +5.68% (misses) in sample
ITL misses in 41 calendar-2025 cells 0 3 not run
Dated check: Table 2.5 2023-24 bands missed 6 of 11 6 of 11 not run
SPI pension rows, total error on the 2023-24 definition −2.0% to −5.0% −0.02% to +1.7% not run
SPI pension rows, total error on the 2025 definition −0.9% to +6.7% +3.4% to +11.3% not run
Sole-income SP allowance (#182), 2027-28: affected, cost 1.235m, £84m 1.117m, £75m 1.326m, £93m
Final calibration loss (in sample) 0.00553 0.00535
  • The SPI rows and the calibration loss are in sample, and each build is closer on the definition it binds. Neither ranks them.
  • The dated check misses the same six bands on both builds, so it doesn't separate them. It does show that the carried-back file misses HMRC's 2023-24 distribution above £50,000.
  • Why the pilot loses is not isolated. The candidates are the 2025 weights, the national age-sex population factor, the engine's carry-back indices, the State Pension's carry-back, and HMRC's projection.
  • Allowance figures come from policyengine.py 6.11.1 / policyengine-uk 2.151.0, dashboard fa9c42b1.

What changes

The four SPI 2023-24 Table 3.6 band rows (hmrc.spi.{state_pension,private_pension_income}.{count,amount}_by_total_income_band) stop being projected to calendar 2025 by HMRC's all-taxpayer growth per nominal band. Instead they bind the published 2023-24 values at 2023-24, following the CGT dated-measure pattern (cgt_2024_*):

  • New dated measures spi_2023_{total_income,income_tax,state_pension,private_pension_income,person_count} (uk_runtime/spi_dated_measures.py, resolver routes in uk_runtime/measure_simulation.py).
    • Carry-back. The engine's source file is carried back to 2023-24 by the engine's own forward rules:
      • savings interest gross of ISA interest;
      • every uprating_indices.yaml input divided by its year-on-year growth;
      • residential stocks by the cumulative house price ratio;
      • year-specific inputs dropped;
      • inputs moved to their _before_lsr/_reported bases, as Simulation.__init__ moves them.
    • Round trip. The engine's own extend_single_year_dataset must return the carried-back file to the stored one for every input column. The resolver refuses otherwise.
    • Measurement. The annual-income simulation runs at 2023. The State Pension pays at 2023-24 rates.
    • Weights. They stay solved at 2025.
  • Population. Counts and amounts carry each person's population factor: ONS's population of their sex and age band at 1 October 2023 over mid-2025, using the same bands as the ons.population.* targets.
  • Materialization guard. A row may now declare a measurement period that is neither the calibration period nor the frame's period, but only when every variable of the row is dated to that year. This holds whether or not the frame carries period metadata.
    • "Every variable" is one inventory of executable reads (_binding_reads): value terms, each filter's variable or concept on its own entity, and the band.
    • This applies to any measure dated by its name, so a CGT row dated throughout (cgt_<y>_*) may now declare its year too. That is intended.
    • Without frame period metadata, a row whose value terms read an engine-computed variable that no name dates is refused under a declared non-calibration period. "Engine-computed" means not in the frame's own stored_columns; a column the resolver injected does not count as stored.
  • Resolver refusals. The resolver refuses three kinds of row:
    • a row that names a spi_<y>_* measure alongside a variable of another year (values, filters, band and value_expression terms);
    • a dated SPI measure bound through a provider kind, named in any field of the binding;
    • any row measured at a year other than the calibration year that also carries an uprating_index, because that would move the fact twice.

Invariants (property-tested in tests/engine_free/uk/test_uk_spi_dated_measures.py)

  1. No target-side projection on these rows. None of the four rows, or their 52 references, carries an uprating_index. Each is bound at measurement_period 2023 with require_matching_fact_period, and every variable of each row is spi_2023_*.
  2. Band partition. Over the published edges, band counts and amounts sum to the unbanded total above £12,570. No one is dropped at an edge or counted twice. This is tested with Hypothesis, including values exactly on an edge.
  3. Carry-back round trip. Indexed inputs divided by the factors and then carried forward by the index return the stored values. Held columns are untouched. Inputs are not mutated.
    • A value missing on one side only is a miss; missing on both sides matches.
    • student_loan_repayments is compared on the rows where the engine kept the loan plan, and must come back exactly zero where it reassigned it, as uprate_student_loan_plans sets it.
    • Bereavement Support Payment is held when both separated bereavement inputs are stored, as the engine holds it; council tax benefit is held by declaration (the engine moves it with its household's council tax, and no dated measure reads it).
  4. Population factor identity. Where the weights meet ONS's mid-2025 band populations, a band's weighted factor sums to its 1 October 2023 population. The factor is 1 when the population is unchanged, and it is positive.
  5. Determinism. The measures and the differential test read only pinned, vendored facts and the engine. There is no randomness.

ITL differential (uk_runtime/itl_differential.py, tools/check_uk_itl_projections.py)

The ITL July 2026 projections become a test of a calibrated file, not targets:

  • Cells checked: Table 2.5 taxpayers by total-income band, Table 2.1 UK taxpayers of State Pension age and 65+, and Table 2.2 by area, all at the calendar-2025 window (3/12 of 2024-25, 9/12 of 2025-26).
  • Tolerance: 5% national, 10% per area.
  • Report: misses by table, band and area, with each cell flagged as bound by a target (in sample) or not.
    • With --diagnostics, "bound" means bound in that run, cell by cell: a diagnostics row (named by band or area) with a missing or positive loss weight.
    • A sum is in sample only when every part is, and nobody the sum counts is outside its parts (outside_parts is zero). The sums are: the all-band count over its bands, the residents' count over its areas, Table 2.1's UK age cell over its twelve areas, and the count below State Pension age over that cell and the all-band count.
    • A diagnostics row binds a cell only at its own period (target_name and period, or the name's trailing @year).
    • Families report in_sample_cells.
    • Only the UK rows of Tables 2.5 and 2.1, and the region-tier rows of Table 2.2, are read.
  • Dated check: --dated-year 2023 checks the file's own 2023-24 taxpayers by band, on the spi_2023_* measures, against Table 2.5's 2023-24 outturn.
  • Engine test: tests/engine/uk/test_uk_itl_differential_build.py runs this on a built file when MICROCOSM_UK_ITL_DIFFERENTIAL_H5 is set.

Invariants, property-tested in tests/engine_free/uk/test_uk_itl_differential.py:

  1. Window weights. The window is a convex combination of the two tax years (weights sum to 1), and it is invariant to row order.
    • Duplicate rows and half windows are refused, among the rows a cell reads.
    • Table 2.5 bands outside ITL_BANDS are refused.
    • The vendored bands sum to the all-band count within 0.5% in every tax year.
  2. Partition. The band cells, with the taxpayers below the lowest edge (£12,570), partition all taxpayers. The area cells partition residents. State Pension age and below it sum to all taxpayers. This holds for any person order.
  3. Sums in sample. Whenever a sum is in sample, its model count equals its in-sample parts' (the sum, or the difference), on random people. A family is in sample exactly when every cell is.
  4. Verdict. A cell misses if and only if |model/expected − 1| exceeds its tolerance.

Hold

uk/uprating_holds.json carries the four families as a proposed matched_period hold: unsigned, status proposed. The compile refuses a target held at an older period without a signed hold (microcosm#1123), so test_every_committed_hold_is_signed fails on exactly these four until María or Max signs. That is the intended blocker. A new test, test_every_reference_measured_at_an_earlier_year_is_declared, requires a declaration for every national reference measured at an earlier year with no index; it catches the failure that stopped the first pilot national.

The A/B pilot national was built from a local branch, never pushed, that fills the hold's approval fields with an explicit "PILOT BUILD ONLY, not a register signature" label.

Review

  • Round 1 (49660f1, Opus 5.5 via Subfleet): request changes. Answered in 87a4729, 1471ebf and 2aa2607.
  • Round 2 (2aa2607, GPT-6.1 Sol via Subfleet): request changes.
    • P1: Bereavement Support Payment and council tax benefit failed the round trip on the real spine, which the engine probe also found. Fixed in 058690b.
    • P2s (concept filters, provider fields, metadata-less frames, per-cell ITL samples), the student-loan limitation and the nits: fixed in 073e9b5.
    • 073e9b5 also lists uk_runtime.spi_dated_measures among the UK target compile modules, since ledger_targets imports it.
  • Round 3 (073e9b5, GPT-6.1 Sol via Subfleet): request changes, with four P2s, three P3s and one plausible risk. All are answered in 58411eb:
    • provider rows have their value reads checked against the declared period (_provider_value_reads, _read_honours);
    • ITL sums carry their parts and outside_parts;
    • diagnostics bind only at their own period;
    • annotation fields (metric_name, notes, source_lines) are skipped by the dated-measure scan;
    • families report in_sample_cells;
    • the hold preflight reads executable bindings and treats a null index as none;
    • entity counts hold no period on either frame;
    • round-trip labels compare by value (same_labels).
  • Round 4: not dispatched. The pilot lost the A/B, so this PR is not heading to merge. Any piece split out to land on its own gets its own review.

Status

  • ITL July 2026 differential, with a stated tolerance.
  • Write-up on extending option 1 to every SPI band family and to the US SOI band rows (docs/evidence/uk-spi-dated-bands/README.md).
  • Engine round trip on the real 2024-25 spine.
  • Baseline vs pilot A/B on one spine, run with real policyengine.py runs for the allowance (above). The pilot does not win.
  • A signed hold: moot unless Max adopts option 1 (decision d2111).

axiom: n/a: microsim calibration-target measurement change, no policy encoded

🤖 Generated with Claude Code

MaxGhenis and others added 9 commits October 27, 2026 13:43
Option 1 of the pensioner calibration review: measure each person's
2023-24 total income, income tax, State Pension and private pension on
the engine (spi_2023_* dated measures), band at the published 2023-24
edges and bind the published values with no uprating_index. Counts and
amounts carry each person's ONS population factor (sex by age band,
1 October 2023 over mid-2025).

Work in progress: target references not yet regenerated, vendored ONS
headers not yet regenerated, measurements pending.

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

The measurement-period guard now runs before a row's band edges are read,
so a band row whose variables sit at two years is refused rather than
skipped for want of its edges. The carried-back inputs go through the
same renames policyengine-uk applies when it loads a dataset (employment
income to its pre-response twin, and three others), so the 2023-24
annual simulation reads pay through its partial-year shares. A guard
refuses any input left without a value at the dataset year.

Council tax, rent, water charges and student loans are projected by
region, tenure or new-borrower rules after the index step; the round trip
records them as held, with the largest miss, and an engine test shows
holding them moves no dated measure.

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

uk_runtime.itl_differential reads HMRC's July 2026 projections at the
calibration year's calendar window: Table 2.5 taxpayers by total-income
band and in all bands; Table 2.1 United Kingdom taxpayers of State Pension
age, aged 65 and over and below State Pension age; Table 2.2 the two age
groups in each region-tier area and summed over them. Each cell carries a
tolerance fixed in the module (5% national, 10% per area) and an in-sample
flag from the contract. tools/check_uk_itl_projections.py runs it on a
national H5; an env-gated slow test asserts no cell misses.

Invariants under property tests: the window is 3/12 and 9/12 of the two
tax years; cells and model counts do not depend on row or person order
(model counts are exactly rounded sums); band cells with the taxpayers
below the first edge, area cells, and State Pension age with below it
partition their totals; a cell is within tolerance exactly when its
relative miss is no larger than its tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--dated-year 2023 reads each person's 2023-24 taxpayer status and total
income from the dated spi_2023_* measures, weighted by household weight
times population factor, and sets the Table 2.5 band counts beside the
year HMRC observed. The two checks share one measure resolver.

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

Inventories the SPI band families and how each reaches calendar 2025, says
which carry the band-projection problem and what dating them needs, and
reads the US SOI AGI band path: stale nominal bins are already refused, and
the rescued rows bind as source-year shares of a bound total, so they keep
the share drift but not the stale levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main measures the four Table 3.6 pension rows on 2025 incomes against the
projected values; the pilot measures them on the dated spi_2023_* measures
against the published values. The script reads one H5 both ways, so each
build is also scored on the definition it was not fitted to, and checks its
own reading against the solver's final estimates on the native definition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round trip now misses by infinity when one side of a value is missing,
and compares student loan repayments on the rows whose plan the engine kept
(uprate_student_loan_plans zeroes a repayment exactly where it reassigns the
plan), instead of exempting them.

The resolver refuses a row that dates SPI measures and reads a variable of
another year, a dated SPI measure bound through a provider, and a row
measured at its fact's year that also projects the fact. Materialization
checks a row's years on a frame without period metadata too.

The ITL check windows only the rows its cells read, refuses Table 2.5 bands
outside ITL_BANDS, marks the all-band count in sample when its bands are,
and reads in-sample from a run's diagnostics with --diagnostics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/generate_uk_target_references.py on the 272c987 national cut, and the
two ONS vendored resources for their new consumer
(tools/vendor_uk_ledger_facts.py). Only the 52 references of the four SPI
2023-24 pension band families move. Each now has period 2025,
metadata.measurement_period 2023, source_period_policy exact_observation and
the published 2023-24 value, with no uprating_index. The membership report
holds them 2023 to 2025 without an index, as the CGT liability row is held.

The State Pension count rows now total the published 7,977,000. Before,
they were projected to 8,361,001.

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

The compile parity surface drops the 52 calibration-drift rows of the four
SPI 2023-24 pension band families: they now bind the published 2023-24
values, which the frozen incumbent fixture also holds, so ledger and fixture
agree. No other row moved.

The UK spine graph fixture repins hmrc_spi_income_spine's stage contract hash,
which covers the ONS population resource whose consumer list this branch
extended. The population-targets closed-world test admits the new
taxpayers_tax_year_2023_24_at_own_year observation basis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 28, 2026 03:16
… engine moves them

The probe on the national spine found two stored inputs the dated SPI carry-back
does not return through policyengine-uk's own forward projection, so the
resolver would refuse every dated measure in a national build:

- bsp_reported: its declared index is CPI, but the engine holds it flat once
  the file stores both separated bereavement benefits (BEREAVEMENT_SPLIT_INPUTS),
  as the FRS spine does. The carry-back now holds it under the same condition
  and records the rule in the receipt (held_by_engine_rule).
- council_tax_benefit_reported: uprate_council_tax moves it with its
  household's council tax factor. It is held and exempted from the round trip
  as council_tax itself is; no dated measure reads it.

The engine test covers split and legacy BSP and a positive council tax benefit.
Also found independently by the round-2 review (P1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a proposed SPI hold

The round-2 review of 2aa2607 found guard gaps in the dated SPI rows and
the ITL check (the P1, council tax benefit and Bereavement Support Payment,
landed in 058690b). This answers the rest:

- Concept filters: the mixed-year checks read one inventory of executable
  reads (_binding_reads): a predicate's variable or concept, on its own
  entity, as _predicate_mask reads it. The resolver reads concept too.
- Provider fields: uk_spi_dated_measures_named scans every string in a
  binding, so a provider that names a dated SPI measure in a field of its
  own (affected_flag_variable, count_of, value_reduction) is refused.
- Metadata-less frames: a row declaring a non-calibration period whose value
  terms read a variable no name dates and the frame does not itself store
  is refused. "Stores" reads the adapter's stored_columns, not has_column,
  which also sees the resolver's injected engine columns. Filters stay read
  at the frame, as at the frame's own period.
- ITL in sample: run_bound_targets keeps each weighted row's name, so a cell
  is in sample by its own band or area; the all-band, residents', Table 2.1
  UK and below-State-Pension-age cells need every part bound. Rows of another
  geography are no longer read.
- Student loans: a reassigned plan's repayment must come back exactly zero,
  as pe-uk sets it, before the kept rows are compared.
- uk_runtime.spi_dated_measures is listed among the UK target compile
  modules: ledger_targets imports it for value_period.
- uk/uprating_holds.json proposes the matched_period hold for the four SPI
  families, unsigned, and a test requires a declaration for every national
  reference measured at an earlier year with no index.

Invariants: a mixed-year or provider-read dated row is refused; an engine
value under a declared non-calibration period is refused without frame
metadata; a sum is in sample only when every part is; the compile refuses
the SPI rows until a person signs the hold. test_every_committed_hold_is_signed
fails on exactly the four SPI families until then, on purpose.

axiom: n/a: calibration target materialization and diagnostics, no policy rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-3 review of 073e9b5 found four P2s and three P3s. This answers them:

- Provider periods: the measurement-period guard now runs for every binding
  that declares a period. A provider's value reads (_provider_value_reads,
  mirroring parameter_gated_threshold and baseline_flag_crosstab) must each
  be held at the declared period by one rule, _read_honours: an entity count
  holds none, a stored or name-dated read holds its own, a stored column on
  a metadata-less frame is read as it is, and the engine computes the rest
  at the calibration period. A provider whose reads are not known here
  (a counterfactual delta, a caller's kind) may declare only the
  calibration period.
- Counts: the entity-count exemption applies in every branch, so a count
  reads alike with and without frame metadata; the resolver still refuses
  a count banded by a dated SPI measure.
- ITL sums: a sum cell carries its parts. differential keeps it in sample
  only when every part is and the file holds nobody it counts outside them
  (outside_parts), so a taxpayer below the lowest band edge, or one in no
  area, keeps the all-band or the UK count out. Families report
  in_sample_cells and are in sample only when every cell is.
- run_bound_targets binds a diagnostics row only at its own period
  (target_name and period, or the name's trailing @year).
- uk_spi_dated_measures_named skips annotation fields (metric_name, notes,
  source_lines) at every depth.
- The hold preflight reads measurement periods from the executable bindings
  and takes a null index as none.
- same_labels compares round-trip labels by value (bytes decoded, numbers as
  numbers), for label columns and the student-loan plan.
- held_by_engine_rule names Bereavement Support Payment only when the file
  stores it.
- The ITL test header no longer says the bands partition all taxpayers:
  with the taxpayers below the first edge they do.

docs/evidence/uk-spi-dated-bands/README.md records the A/B on one spine
(aggregates only). The pilot fits its 2023-24 rows but puts taxpaying
State Pension recipients at 9.26m against HMRC's implied 8.65m-8.81m
(#1414's re-index: 8.74m), misses three ITL Table 2.2 cells main passes,
and lowers the sole-income allowance to 1.117m people and GBP 75m in
2027-28 (main 1.235m, GBP 84m; #1414 1.326m, GBP 93m). It should not
replace #1414's re-index.

Invariants: a declared period no read honours refuses the run, for plain
and provider rows alike; a count holds no period; a sum in sample has its
model count fixed by its in-sample parts (property test); a family is in
sample exactly when all its cells are.

axiom: n/a: calibration target materialization and diagnostics, no policy rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis changed the title Bind SPI 2023-24 pension band rows at their own tax year (option 1 pilot) Pilot (not merged): bind the SPI pension band rows at 2023-24 (option 1) Oct 28, 2026
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Closing unmerged: the option 1 pilot lost the A/B.

What happened. The pilot fits the four SPI pension band rows at 2023-24, but on the out-of-sample tests it is worse than main:

  • It puts taxpaying State Pension recipients at 9.26m, against HMRC's implied 8.65m–8.81m.
  • It puts non-taxpaying recipients at 3.13m, against 3.58m–3.74m.
  • It misses three ITL Table 2.2 cells that main passes.

#1414's re-index (merged) is inside both ranges, so it stands.

Where things are.

  • Head 58411eb answers the round-3 review (GPT-6.1 Sol). It also records the A/B, aggregates only, in docs/evidence/uk-spi-dated-bands/README.md.
  • Locally, the CI engine-free plan ran 21,364 passed and 5 failed. Four failures are the orrery tests, which need the npm @axiom-foundation/orrery package. One is test_every_committed_hold_is_signed, which is the intended blocker on the unsigned proposed hold.
  • Round 4 was not dispatched.

Methodology call. Whether to bind any SPI or SOI band family at its own year is queued for Max as d2111, with a recommendation of no. If he rules to adopt option 1, reopen this PR. The branch is kept.

Reusable pieces. Two parts could land on their own, each proposed separately:

  • the ITL differential check (uk_runtime/itl_differential.py, tools/check_uk_itl_projections.py);
  • a period check on provider reads. On main, provider bindings skip the period check.

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