Skip to content

Fail the calibration build on targets it drops or cannot fit - #595

Draft
MaxGhenis wants to merge 17 commits into
mainfrom
w3-target-coverage
Draft

MaxGhenis wants to merge 17 commits into
mainfrom
w3-target-coverage

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Do not merge on its own. Once approved on its gates, this lands with the d833 uk-data batch on Max's go. It is part of workstream W3 of the Autumn Budget 2026 CI plan: the required-target-coverage gate.

Integration. This branch merges main (a43e8c51), #604 (29ca7542) and #557 (473adafc), and its last commit drops the known gaps those two close. Land it after both, in the order #604, #557, #595.

Why

create_target_matrix builds the national half of the calibration loss. It let targets go in four ways, and none of them fails a build:

  • a target whose name another target already has is dropped without a log line. Main's registry drops 159 targets this way (see "Duplicate names" below);
  • a target whose year does not resolve is skipped, also without a log line (Calibration loss under-penalises overshoots and silently drops targets #456). On 1.58.1 this drops all 15 dwp/uc/two_child_limit/* targets, which are dated 2026 against the calibration year 2025;
  • a target whose column raises is skipped with a warning;
  • a target whose column is zero for every household enters the matrix, where the weights cannot move it (Nineteen calibration targets have no signal on 1.58.1: £1m+ income bands, ages 85-90 and others #593). On 1.58.1 there are 19 of these:
    • ten HMRC £1m+ income bands;
    • ONS ages 85–90 (two targets);
    • three VOA band-H regions;
    • SLC postgraduate repayments;
    • three UC payment bands for couples without children.

Duplicate names

The registry keeps the first target of each name, and the matrix keeps the first across national, region and country levels. On main's registry 159 targets are dropped this way. With #557 merged, 3 are:

What changes

  • build_target_matrix records every offered target's status:

    • in_matrix;
    • duplicate, a target dropped because an earlier one has its name;
    • no_value_for_year;
    • no_column;
    • error;
    • non_finite;
    • zero_signal, meaning a non-zero target with no household contributing.

    It reads every target the sources emit (registry.emitted_targets) and keeps the same targets in the same order as before. So known gaps and duplicates' winners still enter the matrix. Every offered target ends with exactly one status. The status is computed after the column enters the matrix, and a column whose status cannot be computed (a non-numeric column) leaves it as an error, where main kept it.

  • create_target_matrix (require_coverage=True by default, as on the Enhanced FRS build paths in create_datasets.py and the local-area calibrate modules) fails with TargetCoverageError on any target not in the matrix, unless targets/known_gaps.yaml lists it under that same status, with a reason and an issue URL.

    • Errors and non-finite values are never excused, whatever a gap says.
    • A gap applies to every build unless builds limits it to testing (TESTING=1, as pull request builds set) or release. The build kind is read from TESTING, as create_datasets reads it.
    • A testing-only gap may name fnmatch patterns instead of targets. Release gaps must name each target.
    • A name may be listed under two statuses: the ten SPI £1m+ bands are both without signal and duplicated.
    • A listed target that no longer has its status is logged as stale, not failed, so an improvement never breaks a build. A pattern is stale only if it matches no target, because what it excuses varies between testing builds.
    • Stale gaps and problems are also written once each to the GitHub Actions job summary ($GITHUB_STEP_SUMMARY), so they don't get buried in a multi-hour log.
  • utils/loss.get_loss_results (a diagnostic of any dataset) and utils/reweight (only the CPS-based PolicyBench transfer dataset) opt out with require_coverage=False.

  • targets/known_gaps.yaml lists:

  • python -m policyengine_uk_data.targets.coverage <dataset.h5> <year> [out.json] reports every target's status, problems and stale gaps, for the build kind TESTING sets. It outputs names, statuses and counts only.

UC payment bands in pull request builds

The gate failed twice on this PR:

  • run 38726993359 failed on the three SINGLE bands;
  • run 38732018053, after they were listed, failed on COUPLE_WITH_CHILDREN_annual_payment_27_600_to_28_800.

The final calibration logs of 21 pull request builds (8–15 October) show which bands had a national estimate of exactly zero:

Band Builds with zero estimate (of 21)
COUPLE_NO_CHILDREN 27,600–28,800 20
COUPLE_NO_CHILDREN 28,800–30,000 21
COUPLE_NO_CHILDREN 30,000+ 21
SINGLE 24,000–25,200 20
SINGLE 25,200–26,400 20
SINGLE 27,600–28,800 21
COUPLE_WITH_CHILDREN 27,600–28,800 3

Listing these bands one by one would keep failing pull requests, so the testing-only pattern covers the family. Release builds still fail on any UC band without signal, except the three COUPLE_NO_CHILDREN bands listed by name. Pull request and release builds both set PE_UK_DATA_OA_CLONES=1, so the clone count is not a difference between them. The TESTING differences are the epochs (32, not 512), the SPI income-model sample (10,000 records, not 100,000) and the SLC snapshot in place of a live fetch.

Evidence

On the released uk-data 1.58.1 enhanced_frs_2024_25.h5 (sha256 056e80fd), with the locked policyengine-uk 2.122.2 at 2025:

The data test test_built_data_has_no_target_outside_the_matrix_and_the_known_gaps runs the same check wherever the built dataset is present, as in CI's build-and-test job.

Not yet checked: a release build of current main. No non-TESTING pre-calibration dataset built from current main has been run through the gate. The evidence above covers the released 1.58.1 file, which older code built, plus the TESTING builds in CI. So the next release build is the gate's first full-size run. A release build fails safe: if it meets a target the gaps don't cover, it stops at the calibration step.

Invariants (tested)

  • column_status:
    • non-finite beats everything;
    • a zero column is zero_signal only when its target is non-zero.
  • Hypothesis over random statuses, named and pattern gaps, and build kinds:
    • the problems are exactly the targets out of the matrix that no gap for this build excuses under their own status;
    • errors and non-finite values are never excused, even by a hand-built gap that lists those statuses;
    • stale gaps are exactly the named targets without their status, and the patterns that match no target;
    • order doesn't matter.
  • Differential, Hypothesis over random source modules: require_coverage=False builds exactly the matrix and values that main's create_target_matrix and get_all_targets build from the same sources, over random names, levels, years, values, columns and country scopes. These include duplicate names within and across levels, unresolved years, missing and raising columns, and NaN and infinite values. Every target offered at a matrix level gets exactly one status.
  • Through create_target_matrix on a fake simulation:
    • a dropped target or a raising column fails, with the reason in the message;
    • a name two sources emit fails as duplicate, and so does a regional target named like a national one, which loses to the national one even when emitted first;
    • a column that cannot enter the matrix, or whose status cannot be computed, leaves one error status and no column;
    • a gap entry excuses only its own target and status;
    • a non-finite column is never excused.
  • Build kinds:
    • a testing-only gap, named or pattern, excuses nothing in a release build;
    • a release-only gap excuses nothing in a testing build, and isn't stale there;
    • the shipped file excuses any UC band without signal in testing builds, only the three listed bands in release builds, and never an error.
  • Known-gap file:
    • every entry has an excusable status, a reason and an issue;
    • no target is listed twice under one status;
    • patterns are testing-only and must contain a wildcard;
    • the shipped file has the 15 + 17 + 3 named entries measured above and the one UC pattern.
  • test_no_duplicate_names counts what the sources emit, before the registry drops anything. It fails on any duplicate not listed in known_gaps.yaml. Before Calibrate SPI income bands to their projected values, and map the 2023-24 outturn to 2023 #557 it reported all 159 without the entries; with Calibrate SPI income bands to their projected values, and map the 2023-24 outturn to 2023 #557 merged, the three ONS names remain.

Interactions

Tests

At 6e03d4c, run serially:

  • pytest policyengine_uk_data/tests/test_target_coverage.py policyengine_uk_data/tests/test_obr_universal_credit_target.py: 39 passed, 1 skipped (needs the built dataset).
  • pytest policyengine_uk_data/tests/test_target_registry.py: 17 passed (live source downloads).

At a590573, before the integration:

  • Full suite (pytest policyengine_uk_data/tests, no built datasets locally): 2247 passed, 132 skipped, 1 xfailed. Without the opt-outs of get_loss_results and the CPS transfer reweighting, the PolicyBench transfer test fails, because a synthetic dataset has hundreds of zero-signal columns. (That last point was observed at an earlier head and not re-run at a590573.)

axiom: n/a: calibration build gate; no policy change

🤖 Generated with Claude Code

MaxGhenis and others added 5 commits October 10, 2026 17:29
PolicyEngine UK's year N is tax year N to N+1: convert_to_fiscal_year_parameters
gives every parameter, the uprating indices included, its value at 30 April N.
hmrc_spi mapped the 2023-24 SPI to 2024 and hmrc_cgt followed, so both treated
2023-24 outturns as 2024-25 values and every projected year carried one year
too little uprating. The FRS release (2024-25 -> 2024), OBR targets, the SPI
microdata (2022-23 -> 2022) and #533's salary sacrifice tables already use the
start year; the end-year mapping arrived without stated reasoning in the
February 2026 registry refactor (de061fc).

- _SPI_YEAR and _CGT_BASE_YEAR move to 2023; incomes_projection.csv is
  regenerated from the same ODS (its 2023 rows equal main's 2024 rows).
- The CGT base year is now gated at the £6,000 AEA the outturn was realised
  under.
- Tests tie _SPI_YEAR to the tax year in the configured ODS name, check the
  CGT base-year AEA against policyengine-uk, and (as in #541) check that the
  committed projection is the SPI table uprated with the committed factors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_loss_matrix fetches targets without a year filter, and the registry
keeps only the first target of each name. hmrc_spi emitted each band twice:
the ODS outturn (base year only), then the projection (later years). The
projected target was dropped, so _resolve_value carried the base-year outturn
forward unuprated: the 2025 calibration has targeted raw 2023-24 SPI values
since the February 2026 registry refactor. get_targets now merges the two
into one target per band, and a test checks every projected band, variable
and year through the calibration's own resolution path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r2 (APPROVE_WITH_NITS): with the live ODS download failing, the merge
test passed even without the merge, because only the projection targets were
emitted. The test now serves get_income_band_table from incomes.csv, checks the
outturn year as well as the projected years through the calibration resolver,
and requires every SPI target to carry all of them. The comment now names
30 April N, the date convert_to_fiscal_year_parameters samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_target_matrix skipped a target whose year did not resolve with no
log line (#456), skipped one whose column raised with a warning, and
accepted columns no household contributes to (#593). It now records each
offered target's status and fails unless every target is in the matrix
with a finite value and signal, or is listed in targets/known_gaps.yaml
under that status with a reason and an issue. Errors and non-finite
values can never be listed; a listed gap that no longer applies is
logged as stale.

known_gaps.yaml lists the 34 gaps measured on uk-data 1.58.1 at the
calibration year 2025: 15 two-child-limit targets dated 2026 only, and
19 targets with no signal. The matrix built is unchanged. The diagnostic
get_loss_results and the CPS transfer reweighting opt out.

`python -m policyengine_uk_data.targets.coverage` reports every target's
status on a dataset (names, statuses and counts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first CI build of this branch failed the gate on three single-claimant
UC payment bands (24,000 to 28,800 a year). Pull request (TESTING) builds
hold fewer records than the release: across the final calibration logs of
12 recent ones, these three have no signal in 11 or all 12, while the
released 1.58.1 build has signal in all three. A listed gap that applies
to one kind of build only warns as stale in the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 16, 2026 00:18
…test the matrix against main's

Answers Sol's review of #595 (F2, F3, F5, F6, F7).

- Duplicates (F2). build_target_matrix now reads every target the sources
  emit (registry.emitted_targets) and select_matrix_targets keeps the first
  of each name, national then region then country, exactly as before. Each
  target it drops gets the status `duplicate`, which fails the build unless
  known_gaps.yaml lists it. test_no_duplicate_names counts the raw emission,
  so it can fail. On the current registry 159 targets are dropped this way:
  156 HMRC SPI projections (#557) and 3 ONS copies (#605). They are listed;
  a name may be listed under two statuses, since the ten SPI £1m+ bands are
  both without signal and duplicated.
- Build kinds (F3). A known gap may set `builds: [testing]` or `[release]`;
  the build kind comes from TESTING, as in create_datasets. A testing-only
  gap may name fnmatch `patterns`, and one now covers every UC payment band
  in testing builds (#598), replacing the three SINGLE bands; release builds
  still fail on any band without signal but the three COUPLE_NO_CHILDREN
  ones. A pattern is stale only if it matches no target. Errors and
  non-finite values are never excused, whatever a gap says. Stale gaps and
  problems are also written, once each, to $GITHUB_STEP_SUMMARY.
- Status order (F5). The status is computed after the column enters the
  matrix, and a column whose status fails leaves it, so each offered target
  ends with exactly one status.
- Reasons (F6). The 85-90 age gaps are permanent while ages are top-coded
  at 80 (#604); no record can hold a postgraduate loan.
- A Hypothesis differential test checks that require_coverage=False builds
  main's matrix from the same sources, duplicates included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep main's #541 uprating factors, regenerate the aggregate projection CSV with base year 2023, and retain both branches' projection regressions. Isolate SPI registry discovery in the deterministic merge regression.
MaxGhenis and others added 7 commits October 16, 2026 03:41
With #557 each SPI income band is emitted once, so its 156 duplicate entries
go; with #604 the ages are 80 and over, so the two ons/*_85_90 zero-signal
entries go. The shipped-file counts move from 19 to 17 without signal and
from 159 to 3 duplicates (the ONS three, #605). The ten SPI bands of £1m and
over keep their zero-signal entries: #557 gives them no signal.

On uk-data 1.58.1 at 2025 the coverage CLI reports 626 in the matrix, 3
duplicates, 15 with no 2025 value and 17 without signal, with no problems and
no stale gaps, as a release build and as a testing build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(review nit)

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant