Skip to content

Check a UK file against HMRC's Income Tax liabilities projections - #1417

Open
MaxGhenis wants to merge 4 commits into
mainfrom
uk-itl-differential-check
Open

MaxGhenis wants to merge 4 commits into
mainfrom
uk-itl-differential-check

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review: an in-session Opus 5.5 review approved round 1 with minors and nits, all fixed at 1d4ae4c. A GPT-6.1 Sol review approved at 1d4ae4c with one minor (a run estimate of zero dropped out of the model-side comparison), fixed in the fourth commit (49633e3), which a Sol delta review approved with no new findings.

What this adds

tools/check_uk_itl_projections.py and microcosm.build.uk_runtime.itl_differential are a check to run on any calibrated UK file. They compare it with HMRC's Income Tax liabilities statistics (July 2026) projections at the calendar-2025 window (3/12 of 2024-25 plus 9/12 of 2025-26). It is a check, not a target: nothing here feeds calibration or refuses a build, and nothing runs it automatically.

Table Cells
2.5 Income Tax payers in each of the 11 total-income bands, and in all bands
2.1 United Kingdom payers of State Pension age and aged 65 and over, and below State Pension age (the all-band count less the State Pension age count)
2.2 The same two age groups in each of the 12 region-tier areas, and their sum (the residents)

Each cell reports its model count, its relative miss and a tolerance fixed before any file is read: 5% for national cells and 10% for regional ones. Each cell also carries an in-sample flag. With --diagnostics, each bound cell also shows the run's own final_estimate of its target beside the model count read here, as a count difference (model_minus_run_estimate) and, when the estimate is above zero, a relative one (model_vs_run_estimate). The summary counts every estimated cell, those estimated at zero among them (zero_estimates), and reports the largest relative and count differences. That is a model-side differential: a gap means the check and the solve read the cell differently, or the file's weights are not the solve's. --strict exits 1 on any miss.

uv run --no-sync python tools/check_uk_itl_projections.py --h5 <national.h5> --out <report.json> --diagnostics <calibration_diagnostics.json> [--strict]

Ported from #1413, with these changes

The code comes from the closed pilot PR #1413 (head 58411eb). That pilot lost its A/B and closed unmerged; the methodology call is d2111. Its round-3 in-sample logic, from the GPT-6.1 Sol review, is kept unchanged. Three things changed:

  • The dated SPI path is gone. --dated-year, dated_people and SINGLE_TAX_YEAR read the spi_2023_* measures, which never landed on main.
  • The rows are main's vendored hmrc_itl_taxpayer_counts.json. Bind HMRC's pensioner taxpayers by area and re-index the SPI State Pension bands (#1069) #1414 added Tables 2.1 and 2.2 to it. The engine test's MICROCOSM_UK_ITL_DIFFERENTIAL_ROWS override, a stopgap until those tables were vendored, is dropped. The CLI's --itl-rows still swaps in another feed.
  • The in-sample flags are tested against Bind HMRC's pensioner taxpayers by area and re-index the SPI State Pension bands (#1069) #1414's names. Bind HMRC's pensioner taxpayers by area and re-index the SPI State Pension bands (#1069) #1414 binds Table 2.2 (hmrc_itl_age: hmrc.itl.state_pension_age_taxpayers and hmrc.itl.taxpayers_aged_65_and_over in the twelve areas). The new tests check that the cells read those targets by the names the contract's references and a run's diagnostics use: <target>@<GSS code> for areas and <target>.band_<lower>_<upper> for bands. Each cell exposes its name as ITLCell.reference.
  • The no-diagnostics basis is the active references at --year. It reads target_references.json; the pilot read the contract's target ids. A year the references don't carry now binds nothing: the pilot marked all 41 cells in sample at 2024 and 2026. --references replaces --contract, and the two bases are mutually exclusive.
  • Small fixes. The pilot's unused weights parameter is gone. JSONL rows skip whitespace-only lines.

How a cell gets its in-sample flag

  • Bound cells. A Table 2.5 band cell or a Table 2.2 area cell is in sample when its target reference binds at the check's year.
    • With --diagnostics, the basis is the run's calibration_diagnostics.json. A row binds when its weight is above zero, or when it carries no weight; the basis string counts the rows bound that way.
    • Without it, the basis is the tree's active references at --year.
    • No target binds Table 2.1's United Kingdom rows: they also count the roughly 1% of taxpayers who lived abroad or whose address was unknown, whom Table 2.2 leaves out.
  • Cells made of others. The all-band count, the residents and Table 2.1's United Kingdom counts are sums; the count below State Pension age is a difference. Such a cell is in sample only when every part is and outside_parts is zero. outside_parts is the weight of people the cell counts that no part counts, for example a taxpayer below the first band edge or one in no area.
  • Families. A family is in sample only when every one of its cells is.

Worked example (aggregates only)

These were computed locally from files on disk, and none of them is a new national build.

  • Flags from real runs. The Bind HMRC's pensioner taxpayers by area and re-index the SPI State Pension bands (#1069) #1414 candidate's diagnostics (runs/pensioner-1069/candidate/calibration_diagnostics.json) mark all 41 cells in sample before the file is read; outside_parts then decides the sums. The pre-Bind HMRC's pensioner taxpayers by area and re-index the SPI State Pension bands (#1069) #1414 baseline's diagnostics mark only Table 2.5's 12 cells.
  • The window matches the build. On that candidate, the tool's expected value equals the run's bound target value for all 35 bound cells (11 bands and 24 areas): the largest relative difference is 0.0. The new test_the_bound_cells_expect_what_the_build_compiles runs the same comparison in CI against the committed parity receipt.
  • Earlier receipts. Pilot (not merged): bind the SPI pension band rows at 2023-24 (option 1) #1413's A/B receipts (runs/spi-dated-bands/measures/itl_{baseline,pilot}.json) came from the same 256 rows. On main 7d45c0c they show 41 cells and 0 misses; the largest relative difference, +6.44% in the North East's State Pension age cell, is inside its 10% tolerance. On the pilot they show 3 misses.
    • Those receipts predate the round-3 sum rule, so their in-sample flags are stale.
    • The tool was not rerun on an H5 here: the engine measure peaks at about 40 GiB, and the host is under the budget-day-2026-10-28 reservation.

Invariants (property-tested)

Invariant Test
Window convexity: a cell expects the 3/12 and 9/12 combination of its two tax years test_the_window_weights_the_two_tax_years (Hypothesis)
Order invariance: cells, model counts and the report don't depend on the order of rows or people test_the_cells_do_not_depend_on_the_order_of_the_rows, test_model_counts_do_not_depend_on_the_order_of_the_people (Hypothesis), test_the_report_is_deterministic
The bands plus the taxpayers below the first edge partition all taxpayers; the areas partition the residents; State Pension age plus below it make up all taxpayers test_the_bands_and_the_areas_partition_their_totals (Hypothesis)
An in-sample sum is fixed by its parts: an in-sample sum's model count equals its parts'; a family's summary (in sample, cells, misses, largest residual) is recomputed from its cells test_a_sum_in_sample_is_fixed_by_bound_parts (Hypothesis), test_a_sum_is_in_sample_only_when_its_parts_count_everyone_it_counts, test_a_run_that_binds_table_2_2_marks_its_cells_in_sample, test_without_table_2_2_the_united_kingdom_counts_have_no_parts, test_a_sum_given_without_one_of_its_parts_is_out_of_sample
The verdict: a cell misses exactly when abs(model/expected − 1) > tolerance, and --strict exits 1 exactly on a miss test_the_verdict_is_the_tolerance (Hypothesis), test_strict_exits_one_exactly_on_a_miss
No residual equals a tolerance, so the verdict never turns on < against <=: every residual is a multiple of 2⁻⁵³, and 0.05 and 0.10 aren't test_no_residual_lands_on_a_tolerance (Hypothesis)
Differential, expected side: the tool's window equals the build's Ledger compile for every bound cell test_the_bound_cells_expect_what_the_build_compiles
Differential, model side: every cell the run estimates counts in the summary, its count difference is model less estimate, and only a zero estimate leaves it without a relative difference test_every_estimated_cell_counts_in_the_run_summary (Hypothesis), test_a_zero_run_estimate_still_shows_its_difference
Masks match the bindings at their edges: [lower, upper) bands, age ≥ 65, taxpayer, is_SP_age, tier codes only test_the_cells_read_the_bindings_at_their_edges

test_bound_by_names_the_contract_targets_and_their_references checks that BOUND_BY names contract targets of families hmrc_itl and hmrc_itl_age, and that their references are named as the cells read them.

Each of these mutations turns the tests red:

  • renaming a BOUND_BY target;
  • changing the area separator from @;
  • ignoring outside_parts;
  • dropping the bool(parts) guard;
  • dropping the len(parts) guard;
  • age > 65;
  • a reference basis that ignores the year;
  • a run summary that counts only the cells with a relative difference.

<= against < in the verdict is an equivalent mutant (see the table above).

Tests and CI

  • Engine-free: packages/microcosm-build/tests/engine_free/uk/test_uk_itl_differential.py has 43 tests, all passing locally (-n 4). The pilot's 30 tests also passed unchanged on main before the port's edits.
  • Engine: packages/microcosm-build/tests/engine/uk/test_uk_itl_differential_build.py is marked slow and skips unless MICROCOSM_UK_ITL_DIFFERENTIAL_H5 names a built national file.
  • Test plan: tools/ci_test_plan.py verify passes. list-job engine-free lists the first file and list-job engine-uk the second, since ownership comes from the directory.
  • Source-scanning suites still pass with the new module and tool present (312 tests): test_uk_solve_scope, test_transport_graph, test_transport_composed_graph, test_spec_only_country_packages and test_frame_serializer_registry. test_uk_solve_scope was rerun at the head (126 tests).
  • Formatting: ruff format and ruff check pass on the touched files only.

axiom: n/a: diagnostics tool, no policy rule

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 28, 2026 10:57
Port the ITL differential check from the closed pilot PR #1413 (head
58411eb) onto main as a standing check of any UK national build. It
sets a calibrated file's Income Tax payers beside HMRC's July 2026
projections at the calendar-2025 window: Table 2.5's bands and all-band
count, Table 2.1's United Kingdom counts at State Pension age and 65 and
over, Table 2.2's twelve region-tier areas and their residents' sum, and
the count below State Pension age. It is a check, not a target.

The dated SPI path (--dated-year, dated_people, SINGLE_TAX_YEAR) is
dropped: the spi_2023_* measures it read never landed. The rows are
main's vendored hmrc_itl_taxpayer_counts.json, which #1414 extended with
Tables 2.1 and 2.2.

Since #1414 binds Table 2.2 (hmrc_itl_age), the in-sample flags read the
targets by the names its references and a run's diagnostics give them.
New tests hold BOUND_BY to the contract's target ids and references, and
show the contract and a run's diagnostics marking the areas in sample. A
sum stays in sample only when its parts are and outside_parts is zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A differential test: every bound Table 2.5 band and Table 2.2 area cell
expects exactly the value the build's Ledger compile gives its target at
2025, as the committed parity receipt records it, and the checked names
are exactly the contract's references for the families BOUND_BY reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-house Opus 5.5 review approved with minors and nits; this fixes
them.

- Without --diagnostics the in-sample basis is this tree's active target
  references at --year (target_references.json), not the contract's
  target ids, so a year the references do not carry binds nothing.
  --references replaces --contract and excludes --diagnostics.
- With --diagnostics each bound cell carries the run's final_estimate of
  its target beside the model count read here (model_vs_run_estimate), a
  model-side differential; rows bound only for want of a loss weight are
  counted in the in-sample basis.
- Cells name their target reference (ITLCell.reference), shared with the
  bound-target match.
- The unused weights parameter is gone.
- JSONL rows skip whitespace-only lines.
- New tests pin the bool(parts) and len(parts) guards, the mask edges
  (band edges, 65, the taxpayer and State Pension age flags, the tier
  codes), the year of the reference basis, the JSONL branch, unweighted
  rows and the mutually exclusive bases. The family invariant is now
  recomputed from the cells, and a property test shows no residual can
  equal a tolerance, so the verdict never turns on < against <=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GPT-6.1 Sol review found that a bound cell the run estimated at zero
got no relative difference and fell out of the run-estimate summary, so a
model count of 10 beside a zero estimate reported no comparison at all.
Each estimated cell now also carries its count difference, and the summary
counts every estimated cell, names the zero estimates and reports the
largest count difference beside the largest relative one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 28, 2026 17:32

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