Skip to content

UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year - #1344

Merged
juaristi22 merged 19 commits into
mainfrom
uk-annual-income
Oct 23, 2026
Merged

juaristi22 merged 19 commits into
mainfrom
uk-annual-income

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1334: fix 2 and the annual-income concept that replaces the dropped fix 1 land here (María's ruling of 21 October: one PR to main). Fix 3 is dropped (María's ruling of 22 October; see "Fix 3 is dropped"). Targets main: the branch sits on main at 65e5eaeb0, after #1333 and #1338, with 49 spine stages, on policyengine-uk 2.147.2. Vahid's four review rounds are answered in the comments.

Why

#1334 found that the national calibration lowers modelled poverty well below HBAI. It named three row families that pull:

  • UC;
  • pensioner benefits;
  • the HMRC income-band rows below £30k.

For UC, the spine's benefit receipt does not match DWP at the solve's starting weights. To meet DWP's caseloads, the solve then moves weight away from low-income families. Fix 2 corrects that receipt on the spine. Pensioner benefits stay as the FRS records them, because the FRS takes them from administrative data (see "Fix 3 is dropped").

For the HMRC rows, the gap is one of concept. HMRC's income statistics count the income each taxpayer received over a tax year, banded by what they received. They cover individuals liable to UK income tax, so a leaver counts only where the year's income makes them liable. The spine carries current, annualised incomes (usual weekly pay × 365.25 / 7). A person who started, left or changed a job during the year therefore sits in another HMRC band than their current pay gives, or in none. A recent leaver who paid tax in the year is no taxpayer at all on current income. The solve then raises the weight on current earners and drains low-income households. The annual-income concept records each adult's year beside their current income, and the national HMRC rows read it. That year is a proxy for the tax year, not the tax year itself: the 12 months before each FRS interview (April 2024 to March 2025), applied to the engine's 2025 amounts and fitted to HMRC targets for calendar 2025 (the SPI 2023-24 rows uprated to it, and the Income Tax liabilities rows' calendar-2025 window). Targets do not change: we do not alter targets.

So the PR closes part of the gap only. In 2024, against #1338's certified build, relative BHC poverty rises from 13.7% to 14.3% (HBAI FYE25: 15.9%), and absolute BHC poverty rises by 1.5pp.

Fix 3 is dropped (María, 22 October)

Fix 3 imputed Attendance Allowance receipt to pensioners in England and Wales who report none but have a care or disability need, and drew pension-age Housing Benefit claims among entitled non-reporters. Its premise was that the FRS under-reports both. But the FRS 2024-25, like HBAI FYE 2025, takes both from administrative data: AA through DWP's General Matching Service and HB through SHBE, for about 95% of respondents. A sampled pensioner who reports neither almost certainly receives neither. The FRS's shortfall against DWP's caseloads (AA about 20% below administrative counts in DWP's own comparison) comes mostly from people the survey does not reach: care-home residents, caseload growth since the survey year, and coverage and weighting. Imputing receipt to sampled non-recipients therefore added income where there is none, and moved pensioner poverty away from HBAI. The calibration's AA and HB rows handle a coverage gap by reweighting, as before. A coverage-based redesign (aligning the rows' universe with the FRS, and at most a small draw for caseload growth since the survey year) is left for after the Budget. Vahid's items 3, 4, 5, 6 and 11 concern fix 3's code, and go with it.

The AA stage, the HB stage, the AA stage's move after the property stages and the AA/HB receipt banding are removed, and the roster loses the two stages.

What changes

  1. UC take-up by award band and family type (UK national calibration moves weight away from low-income households: reconcile the HMRC low-band rows, model UC take-up by award size, impute pensioner benefit receipt (pre-Budget) #1334 fix 2; new spine stage uc_take_up).
    • Entitled non-reporters claim at a rate set per DWP family type and £100 monthly award band, so claimants move toward DWP's Stat-Xplore counts at the solve's starting weights.
    • The match is as close as reported receipt and entitlement allow: couples' reporters alone exceed DWP's count, and every lone-parent pool is short of entitled units.
    • DWP's counts are vendored as dwp_uc_award_band_facts.json from the abf0921 pin.
    • A unit that reports a legacy benefit the engine does not move at the legacy closure is not drawn onto UC, because policyengine-uk ends the kept benefit once the unit claims UC; it counts in F at its stored flag (review item 7). The receipt reports the working-age units that report Housing Benefit and keep it, before and after the redraw, beside DWP's 2025 caseload. The comparison with DWP's caseloads is UK spine: the early flat UC draw moves working-age Housing Benefit reporters onto UC #1352.
    • A pool can draw no claimant when zero is nearest DWP's count (review item 15).
    • A new release-blocking stage gate, uk_stage_uc_take_up, measures the draw by band. The terminal take-up signal gate drops its UC branch and keeps UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's under-2 measurement (review item 21).
  2. Withholding of thin residuals (review item 17).
  3. Each adult's year of income on the spine (the annual-income concept; new stage frs_annual_income_spells, after frs_hmrc_spine_leaves).
    • It reads the FRS 2024-25 adult, job and household tabs and writes six person descriptors:
      • the share of the year at today's employee pay, and at today's self-employment profit;
      • annual pay from other spells, chiefly a recent leaver's last job. That job's usual gross pay is scaled to the DV pay concept by a ratio the stage measures and uprated by average earnings; without it, a donor's rate;
      • ceased self-employment, its amount uprated by average earnings for the months since it ceased;
      • the shares of the year a private pension (after retiring in it) and contributory JSA, ESA and Incapacity Benefit (after leaving work) were in payment.
    • No current column changes, so poverty, benefits and the engine's published tax read current income as before.
    • On the SPI channel, a drawn copy's descriptors follow its drawn incomes. The tape's income is the tax year's, so each share is 1. The twin's other spells stay only where the tape drew none of that income. An undrawn copy keeps its twin's descriptors.
    • New release-blocking stage gate, uk_stage_frs_annual_income_spells. It checks:
      • the descriptors' invariants (the receipt bands its structural counts with extreme_count, zero exact);
      • the measured usual-to-DV pay ratio, within 0.95–1.10;
      • annual over current employee earners, within 1.0–1.15;
      • where ten or more households carry it, that at least 0.7 of the pay outside the current job is observed rather than drawn from a donor.
    • The receipt gives support bands only.
    • The descriptors join the export surface, because the rule-1 scorer reads the exported file. The SPI income family requires the stage.
  4. Annual measures in the UK measure resolver.
    • employment_income_annual, self_employment_income_after_loss_relief_annual, private_pension_income_annual, state_pension_annual, total_income_after_loss_relief_annual, income_tax_annual and tax_band_annual.
    • Each is its current measure on one more policyengine-uk simulation per resolver, whose inputs are each person's year. So the engine's own rules give the tax on that year's income, and no policyengine-uk change is needed.
    • policyengine-uk projects a dataset's inputs to its later years as it loads it. So the year's inputs are set at the measurement year: the engine's current amount times the annual share, plus the other spells carried by the engine's own factor for that input. That factor must be the same for every row.
    • The State Pension takes the share of the fiscal year paid to someone who attains State Pension age within it. The earnings-tested carer benefits keep their baseline values.
    • Pension contributions and salary sacrifice keep their current values. For a part-year entrant they can be large against the year's pay, which lowers income_tax_annual a little.
    • A frame without the descriptors refuses every annual measure, with one exception. In rule 1, the incumbent eFRS reads its current values as its annual ones, and the score receipt names that route.
  5. The national HMRC income rows read the annual measures.
    • This covers the 49 declarations of hmrc_spi (14), hmrc_spi_region (30), hmrc_itl (3) and hmrc_itl_marginal_rate (2), plus obr.income_tax.
    • It covers their values, total-income bands and filters, the taxpayer test and the rate band.
    • Target ids, facts, values and metric names do not change. Dividends, property and savings income have no spells and keep their measures.
    • The changelog says what this does to reform costings on files built from this code (see Measurement).
  6. The spine roster goes from main's 47 to 49 stages, with uc_take_up and frs_annual_income_spells (fixture, fixture tool, stage tests and docs).
  7. The measurement-period guard's docstring now says that it checks value terms only (review of UK pre-Budget data: measure stored values at their period, draw Carer's Allowance claims, and the #1278 items (#1324, #1319, #1278) #1329, item 8).
  8. CI fixes: the spine build's import order, and the shipped-manifest test's stage list.
  9. The gate-battery re-pin and the pin pass on policyengine-uk 2.147.2, last, then a changelog commit that restates the costing figures from the measurement below.

Rulings (María)

Measurement (review item 8)

Builds. Both on policyengine-uk 2.147.2, review date 2026-10-23.

Gates. All seven calibration-seam gates and all seven terminal gates pass in both.

Fit.

Weights.

  • ESS: 7,359, against 6,853.
  • Top-1% weight share: 24.3%, against 25.3%.
  • Rows whose model figures the diagnostics withhold: 56, against 45. The residual check (item 17) adds some of them.

OBR tax rows (final estimate against target):

  • income tax −0.11% to −0.02%;
  • employee NICs +4.07% to +1.94%;
  • self-employed NICs −0.08% to +0.10%.

Before the solve (relative error at the solve's prior weights, from the builds' initial estimates; these are not FRS design weights), control to this PR:

  • UC households by family type:
    • single, no children +13.6% to −3.5%;
    • couple, no children +90.1% to +28.2%;
    • couple with children +55.7% to +16.6%;
    • single with children −35.2% to −30.0%.
  • UC payment bands: most move closer, and the target-weighted absolute error falls from 53% to 21%.
  • OBR UC amount: −12.9% to −15.4%.
  • Pension Credit: unchanged.

Poverty and inequality. From the dashboard's statistics on policyengine-uk 2.147.2, on this build and on the control. Changes are computed before rounding. HBAI FYE25 (2024-25) is the comparator for the 2024 column; 2025-26 has no outturn yet.

Measure 2024: control → this PR 2025: control → this PR HBAI FYE25
Relative BHC, all 13.7% → 14.3% (+0.6pp) 14.0% → 14.6% (+0.6pp) 15.9%
Relative BHC, children 18.5% → 18.9% (+0.3pp) 19.6% → 19.9% (+0.3pp) 20.5%
Relative BHC, working age 12.0% → 13.2% (+1.2pp) 12.3% → 13.4% (+1.1pp) 14.3%
Relative BHC, pensioners 13.6% → 12.7% (−0.9pp) 13.5% → 12.5% (−1.0pp) 15.9%
Relative AHC, all 17.3% → 17.9% (+0.6pp) 17.7% → 18.5% (+0.9pp) 19.6%
Relative AHC, children 24.5% → 25.3% (+0.8pp) 25.5% → 26.7% (+1.2pp) 27.4%
Relative AHC, pensioners 13.0% → 12.2% (−0.9pp) 12.8% → 12.1% (−0.8pp) 13.9%
Absolute BHC, all 12.1% → 13.6% (+1.5pp) 12.2% → 13.7% (+1.5pp) —
Absolute BHC, children 16.1% → 17.9% (+1.8pp) 17.0% → 18.8% (+1.8pp) —
Absolute AHC, all 15.9% → 17.6% (+1.7pp) 16.4% → 18.2% (+1.7pp) —
Absolute AHC, children 22.6% → 24.9% (+2.3pp) 23.9% → 26.2% (+2.3pp) —
Gini, BHC 0.299 → 0.305 0.300 → 0.306 0.327
  • Absolute rows have no HBAI comparator here: the dashboard's outturn file anchors HBAI's absolute line to FYE 2025, so its absolute rates equal its relative ones.
  • In 2024, relative poverty for all persons, children and the working age, and the Gini, move toward HBAI. Pensioners' relative rates move away (see Regressions).

Threshold costings (static, 2025), beside HMRC's ready reckoner:

Change Control This PR Change HMRC 2025-26 ready reckoner
+£1,000 personal allowance (cost) £8.98bn £8.81bn −1.9% about £7.7bn to £7.8bn (scaled from its £100 row, £0.78bn, and its 10% row, £9.65bn)
1p on the basic rate (yield) £6.62bn £6.51bn −1.7% £6.55bn
  • HMRC's figures are the January 2025 "Direct effects of illustrative tax changes" (Autumn Budget 2024 forecast, April 2025 implementation). They include behavioural responses, and HMRC says allowance changes scale only roughly; the model's figures are static. So the comparison is a direction check, not a match.
  • Both model costings move toward HMRC's: the basic-rate yield from 1.1% above HMRC's figure to 0.6% below it, and the allowance cost from 15% to 17% above HMRC's range to 13% to 14% above it.
  • For users: anyone scoring reforms on a file built from this code gets these lower figures. The engine's income tax on current income is £321.5bn, against £324.7bn (−1.0%), and now sits below OBR's receipts by construction. The changelog says so.

Rule 1 against the pinned eFRS: passed. The incumbent reads its current values as its annual ones, as ruled, so on the rows of the 50 rebound declarations the two files are scored on different concepts. Those declarations fan out to 590 of the 1,132 scored rows (hmrc_spi 172, hmrc_spi_region 360, hmrc_itl 33, hmrc_itl_marginal_rate 24, obr.income_tax 1). Rule 1 does not rest on them:

Rows Candidate loss Incumbent loss Candidate wins
All 1,132 scored 0.0066 0.3474 1,108
The 542 not rebound 0.0045 0.3674 533
The 590 rebound 0.0085 0.3290 575

(Recomputed from the receipt's per-row relative errors, which reproduce its two losses exactly.)

Memory. The national build's peak resident set is 22.7 GB. Its measure resolver holds one more engine simulation than main's.

Regressions and moves away from targets

  • Pensioner poverty moves further from HBAI: relative BHC −0.9pp and relative AHC −0.9pp in 2024. The cause is the relative poverty line, which the new weights move, not pensioners' incomes: on the interim 2.145.0 pair, absolute pensioner poverty did not move.
  • Before the solve, the OBR UC amount sits further from its target (−12.9% to −15.4%), and some payment bands move further. Single people with children stay about 30% under DWP's count before the solve.
  • Pension Credit stays about 26% under its target before the solve: fix 3 is dropped, and PC receipt is not imputed.
  • Working-age Housing Benefit: fix 2 no longer draws legacy-benefit reporters the engine keeps on their benefit (item 7). How the kept caseload compares with DWP's is UK spine: the early flat UC draw moves working-age Housing Benefit reporters onto UC #1352.
  • Annual concept, thresholds: the threshold costings fall 1.7–1.9% and the engine's current-income tax total 1.0% (above).
  • Withheld rows rise from 45 to 56, so fewer rows carry model figures in the diagnostics.
  • Concept split on the dense line (annual income). The national HMRC rows read annual income, while the 5 local hmrc rows still read current income. The national-to-area fan-out bridges reconcile the targets, not the measures, so nothing refuses. But a dense build would fit the two grains on different concepts until UK #1334: compute the local metrics from their declarations, and measure the local HMRC rows over each person's year #1347 lands.

For review

  • Two new release-blocking stage gates: uk_stage_uc_take_up and uk_stage_frs_annual_income_spells. gates.json moves, with the take-up signal gate's note ("DfE age 0 or 1"), so the gate-battery and certification pins move too.
  • The take-up contract: the U8 record.
  • The residual withholding check (item 17). It runs wherever Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333's withholding runs, on every written surface. On a national problem a call takes about 10 s, and a total past the search's caps is searched over greedy groups, with a warning. Any release cut from main before this PR merges lacks it.
  • The annual-income spell rules (frs_annual_income.py).
    • The FRS calendar records the main activity only and under-records change, so it decides months only where it records a change. It never outranks months in the job, the previous activity or the weeks of regular work.
    • It bounds a business only when self-employment is today's activity.
    • A leaver's usual pay, and a ceased business's amount, are uprated for the months since.
  • The rule-1 fallback for an incumbent without descriptors (candidate_score._scored_frame(incumbent=True)). The two files are scored on different concepts for the rebound rows; rule 1 passes without them (above). The receipt states the route.
  • Six new exported person columns (the descriptors). The other-spell amounts are £ values per person, like the existing income columns. The release disclosure walk covers them before the first certified cut.
  • One more engine simulation per measure resolver. On the national line that is one more simulation in memory (peak 22.7 GB on the build above). On the dense line it is one per clone block, so measure memory before a dense build.

Tests

  • CI runs the full suite on the head b0471dde6, which changes only a changelog fragment after e399d21ef.
  • Vahid's fourth review ran the 21 test files the PR touches at e399d21ef: 1,051 passed, 2 skipped, 0 failed. tools/ci_test_plan.py verify: ok.
  • The pin pass regenerates the coverage manifest and the H2 spine fixture on policyengine-uk 2.147.2. The national and local compile-parity receipts do not move.

Not in this PR

🤖 Generated with Claude Code

juaristi22 added a commit that referenced this pull request Oct 21, 2026
…manifest test

CI on #1344: ruff's import order in spine_build.py, and the shipped-manifest
test's required build stages, which lacked frs_annual_income_spells beside
the regenerated manifest.

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

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my review at 16a2cb836.

Verdict. This is careful work. The three stages and the annual-income concept go at the right causes of #1334, and the receipts are disclosure-aware throughout. I checked the vendored DWP and ONS inputs against the publishers and they hold. I would not merge it yet, for two reasons. First, it collides with #1333 on the signed support-floor block, and #1333 is the PR that carries the withholding that floor 1 needs (item 1). Second, the stated reason for the pensioner-poverty regression is wrong: HBAI FYE 2025 takes Attendance Allowance and Housing Benefit from linked admin data, not from survey reports, so that move is still unexplained (item 2). The rest are shoulds and nits. The biggest of them is that the AA stage nets out care homes but the bound AA rows do not (item 3).

What I ran

Generated data versus code. About 60.6k of the 70.2k added lines are two vendored DWP fact files: dwp_uc_award_band_facts.json (56.0k, 1,215 rows) and dwp_attendance_allowance_caseload.json (4.6k, 96 rows). A further ~1.3k lines are regenerated fixtures and manifests: the uk_spine fixture and FRS tabs, release_input_coverage_manifest.json, source_stages.json/sources.yaml, and contract hashes.

  • Read in full: the four new stages, the measure-resolver and scorer changes, the support-floor change, the SPI-channel changes, the gates and the take-up contract diff. I checked that the uk_population_targets.json diff only renames measures to their _annual forms (49 declarations plus obr.income_tax) and rewords their selector notes. No values or ids change.
  • Spot-checked: monthly totals, family-type means, vintage and record kind of the UC file; country and quarter totals of the AA file; the stage order in source_stages.json.
  • Not read row by row: the 1,215 UC rows, the fixture tabs, and the generated sources.yaml and source_stages.json blocks beyond the stage order.

Findings

1. must: support floor and withholding collide with #1333. uk/calibration_measure_exclusions.json:11 and uk_runtime/support_floor.py.

Both PRs re-sign the same block on 2026-10-21 at floor 1, but they say different things:

With floor 1 and no withholding, a build from this branch writes thin-cell model figures into its diagnostics. Only a sentence in the PR body stands between those files and publication. main already withholds BRMA rows this way, so the machinery exists.

Suggested fix: merge #1333 first, rebase this PR onto it, drop commit 9d468f895 (or reduce it to whatever #1333 does not already cover), and drop the "no withholding" caveat. The two adjudication texts then need to become one ruling. git merge-tree already conflicts on the block, support_floor.py, its test, sources.yaml and person.csv.

2. must: the explanation for the pensioner-poverty regression does not hold. The PR body says "HBAI uses the FRS-reported amounts, which under-report both". That is not true of the comparator used.

  • The HBAI FYE 2025 methodology says "administrative data has been integrated into the FRS to replace survey responses for the major DWP and HMRC benefits".

  • The FRS transformation note lists AA (linked through the General Matching Service) and HB (through SHBE) among those benefits. DWP adds that some under-reporting "has not been eliminated".

  • So HBAI's pensioner AA and HB are already mostly admin-based. Relative AHC pensioner poverty moving 0.8pp further from HBAI (13.9%) is evidence that the imputed AA and HB may overshoot what HBAI's own linked receipt shows. It is not something HBAI's design explains.

  • The same holds for the spine's own input. The FRS 2024-25 methodology says "Information on the major state benefits and tax credits is now based on administrative data", and that "the level of underreporting of benefits has reduced". So the AA stage docstring's premise, "The survey under-reports the allowance", needs the same correction. The reporters' 0.70 of DWP in England is probably not mainly under-reporting. Other parts are care homes (about 9%, netted by the stage), unlinked or non-consenting respondents, and survey-year design weights set against calendar-2025 counts. The AA caseload grew about 4.7% from February to November 2025, and UC about 10% from April to December.

  • That last part matters for all three stages. They fill a gap measured at 2024-25 design weights against calendar-2025 counts. So a year of caseload growth goes into take-up and imputed receipt, where the solve would otherwise close it by reweighting.

Before the regression is accepted, please correct the PR body and the stage docstring, and say what does explain the move. Item 3 is one candidate: the solve still pulls AA recipients up to a gross caseload. Splitting each stage's gap into those parts would show how much of it is under-reporting at all.

3. should: the AA stage and the bound AA rows use different universes.

Please either net the two rows (and obr/attendance_allowance) by the same estimate, or record that the residual is accepted.

4. should: the care-home estimate mixes bases. ONS's 37.0% (137,480 of 372,035) is the self-funder share of all adult residents of CQC care homes in England, 2022-23. The stage applies it to residents aged 65 and over in England and Wales. Self-funding is likely higher among older residents and lower in Wales. Separately, DWP's 6% (99,000) is a share of the 1,744,000 policy-ownership total, which includes unknown and overseas claims. The PR already calls this the weakest step. It would help to state the direction of each difference in attendance_allowance_care_homes.json and in the receipt.

5. should: imputed AA rates ignore the need tier. attendance_allowance_receipt.py:648 gives a drawn person the higher rate on an independent uniform at the band's higher-rate share. A person in the "disabled_other" tier is then as likely to get the higher rate as someone helped day and night. The higher rate is the day-and-night test, and it sets the enhanced and severe flags that Pension Credit reads. Ranking the higher rate by tier (tier 1 first) would cost little and match the rules better.

6. should: the HB take-up rate has been superseded today. take_up_contract.json:133 sets 0.85 with the source "Income-related benefits: estimates of take-up, FYE 2024". That is correct for FYE 2024: 85%, range 83 to 88. DWP published FYE 2025 on 22 October 2026: "83% of pensioner families entitled to HB claimed the benefit in FYE 2025", range 80 to 86. Since the calibration year is 2025, please add "2024-01-01": 0.83 beside the FYE 2024 value (with its source), or record why FYE 2024 is kept. The same release gives DWP's first UC take-up estimate: 72% caseload and 87% expenditure for FYE 2025. It is a composite of UC and legacy equivalents, so it is not a direct target, but the stage's implied take-up by family type could be reported beside it as an outside check.

7. should: measure working-age HB, don't just describe it. uc_take_up.py:415 makes drawable = entitled & population & ~reporter & ~moved.

  • A unit that reports HB but not UC, and that the engine does not move off legacy benefits, can be drawn onto UC.
  • policyengine-uk then ends its HB (continuing_award = already_claiming & ~claiming_uc & ~migrated). This is the two-thirds fall the PR names.
  • Real working-age HB was itself falling fast in 2025: 660,000 claims in May and 370,000 in November (DWP benefit statistics, August 2025 and February 2026).
  • So the fall may land near the 2025 level, but by take-up draws rather than migration, and with no gate or row checking it.

Please add the working-age HB caseload before and after the stage, at design weights, to the receipt, and compare it with DWP's 2025 figures. If it overshoots, exclude HB and ESA(IR) reporters from drawable and count them in F at their stored flag.

8. should: the evidence predates the head. The measurement tables come from builds before the rebase onto #1332, which moves the SPI draws. They mix engines: policyengine-uk 2.134.4 for the builds and 2.130.1 for the poverty script. And #1333 locks 2.145.0. Please post the 22 October build on the merged code, on one engine version, before merge.

9. should: the rule-1 comparison is not like for like on the re-bound rows. At candidate_score.py:308, the incumbent's annual measures are its current values, while the candidate is scored on its annual concept, against the same annual targets, for about 50 rows. The candidate is partly favoured by measuring closer to the targets' concept. Please also report rule 1 with those rows left out, so the pass does not rest on them.

10. should: state what "the year" is. There are three different years in play:

  • The descriptors cover the 12 months before each interview (interviews April 2024 to March 2025).
  • They are applied to engine amounts projected to 2025.
  • They are fitted to SPI 2023-24 uprated to calendar 2025.

That is a defensible proxy, but the body and the stage docstring call it the tax year. Separately, "anyone paid at any point of it" overstates HMRC's coverage. The SPI tables cover "individuals liable to UK Income Tax (taxpayers)", so leavers count only where their year makes them liable. The code is right here: the filters use income_tax_annual > 0. Only the wording needs to change.

11. should: storing would_claim_housing_benefit changes behaviour for every user of the file. Users of the released file who set claims_all_entitled_benefits (for example, full-take-up counterfactuals) no longer move HB. The PR notes this for reviewers, but it belongs in the changelog and the take-up contract notes too, or in a policyengine-uk change that ORs the stored flag with the switch.

12. should: say plainly what the costing shift means for users. The PR states that the engine's income tax on current income falls 1.3% (£325.1bn to £321.0bn). Static threshold costings fall 1.7% to 2.0%, and this was accepted as a concept correction. Anyone scoring reforms on the released file gets those lower figures, and the current-income tax total now sits below OBR's by construction. Please:

  • put one sentence on this in the changelog and the release notes;
  • set HMRC's direct-effects ready reckoner for 2025-26 (+£1,000 personal allowance, 1p on the basic rate) beside the model's old and new costings. That gives the "concept correction" an outside check, showing which concept lands nearer HMRC's own costing.

13. nit: the annual simulation (measure_simulation.py:276) scales pay, profit, pensions and contributory benefits. It leaves pension contributions and salary sacrifice at their current-year values. For a part-year entrant those can exceed the year's pay, which lowers income_tax_annual a little. Worth a sentence, or a share on those inputs too.

14. nit: the module docstring of frs_annual_income.py says no self-employed amount is uprated again, but frs_annual_income.py:693 multiplies a ceased business's SEINCAMT or PROFIT1 by uprating. One of the two should change.

15. nit: _realized_rate (uc_take_up.py:341) cannot give zero claimants once 0 < residual < 1. With two drawn units of weight 100 and a need of 10, it returns one claimant (100) where none (0) is nearer. Pools of at least 30 sources and the 5% gate make this small.

16. nit: the annual-income receipt gives its structural counts (full_year_rows_not_whole, rows_outside_share_bounds, rows_negative_other_spells; frs_annual_income.py:943) as exact ints. The other three stages use extreme_count, so a failing build there never carries 1 to 9.

Merge order and overlaps

What looks right

  • UC facts match DWP. The vendored totals agree with the bulletins: 6.9m households in August 2025, 7.1m in November. DWP defines the award as "the amount received … after any deductions". So banding the engine's award after the benefit cap and the assigned deductions is the right concept. Dropping the "No payment" band, which DWP's household count includes, is consistent with only drawing units that have an award.
  • AA inputs check out. The 99,000 quote is exact, for August 2025, excluding Scotland. The August England-plus-Wales caseload (1,736,297) sits about 7.7k under DWP's 1,744,000 headline, which fits the unknown and overseas claims. ONS's 278,946 residents aged 65 and over is confirmed, and the stage's cells sum to it within one.
  • The FRS CARE table can find the right people. It records care received by household members from anyone, including non-resident family and paid carers (NEEDPER 1 to 14, FREQ 1 to 3 daily, DAYNIGHT 3 both). So a pensioner living alone can reach tiers 1 to 3. CONDIT 1 is "Yes, a lot".
  • The HBAI FYE 2025 comparators are all correct. Pensioner BHC 15.9% equals the all-persons figure by coincidence (15.895 against 15.912). The BHC Gini of 0.327 is right.
  • The SPA timing is right. state_pension_annual_share matches policyengine-uk's months_since_state_pension_age, which counts to 6 October.
  • The annual measures run through the engine's own rules on a second simulation, with a single-factor check on the projection and a read-back check. No policyengine-uk change is needed, and targets are untouched.
  • The receipts are disclosure-aware. They use support bands and <10 throughout, and the AA cells are suppressed against differencing from the country totals.
  • The four new stage gates are release-blocking and test structural zeros exactly.

🤖 Generated with Claude Code

juaristi22 added a commit that referenced this pull request Oct 22, 2026
… review)

Vahid's review items 10, 12, 13 and 14. The year the descriptors describe is
a proxy for HMRC's tax year: the 12 months before each FRS interview (April
2024 to March 2025), applied to the engine's 2025 amounts and fitted to HMRC
targets for calendar 2025. HMRC's statistics cover taxpayers, so a leaver
counts only where the year's income makes them liable; the stage docstring,
the measures' comment, the changelog and 15 selector notes no longer say
"anyone paid at any point" or call the window the tax year.

The national-rows changelog says what the concept does to reform costings
on a file built from this code, beside HMRC's 2025-26 ready reckoner
(January 2025). The annual simulation's docstring and the measures'
changelog say that pension contributions and salary sacrifice keep their
current values. The stage docstring now says a ceased business's amount is
uprated for the months since it ceased, as the code does; only continuing
self-employment is not uprated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
Vahid's review item 16. full_year_rows_not_whole, rows_outside_share_bounds
and rows_negative_other_spells are recorded with receipt_counts.extreme_count,
as the other stages record theirs: zero stays exact, so the stage gate still
reads each against 0, and a failing build never records 1 to 9 rows.

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks for the review. This reply covers the annual-income items (9, 10, 12, 13, 14 and 16), now pushed as e48ba14eb and 8db7e0464. Items 1–7, 11 and 15 (the floor, AA, HB and UC take-up) are in progress on a separate branch and will follow here. The PR body will take all the review corrections in one edit.

9. Rule 1 without the rebound rows. The 50 rebound declarations fan out to 588 of the 1,103 scored rows (hmrc_spi 170, hmrc_spi_region 360, hmrc_itl 33, hmrc_itl_marginal_rate 24, obr.income_tax 1). Rule 1 does not rest on them:

Rows Candidate loss Incumbent loss Candidate wins
All 1,103 scored 0.0059 0.3383 1,080
The 515 not rebound 0.0030 0.3504 503
The 588 rebound 0.0085 0.3277 577

These come from the annual arm's score receipt. Its per-row relative errors reproduce both of its losses exactly, so the subsets need no new run.

10. What "the year" is. Agreed. The text now says the year is a proxy for HMRC's tax year: the 12 months before each FRS interview (April 2024 to March 2025), applied to the engine's 2025 amounts and fitted to HMRC targets for calendar 2025 (the SPI 2023-24 rows uprated to it, and the Income Tax liabilities rows' calendar-2025 window). "Anyone paid at any point" is gone. HMRC's statistics cover taxpayers, so a leaver counts only where the year's income makes them liable. The change is in the stage docstring, the measures' comment, the three changelog fragments and the 15 contract notes (13 selector notes and 2 binding notes) that said "over the tax year". The code does not change: the filters already use income_tax_annual > 0.

12. Costings for users. The national-rows changelog now says what someone scoring reforms on a file built from this code gets, beside HMRC's ready reckoner. HMRC's current edition (June 2025) starts at 2026-27, so the 2025-26 figures are from the January 2025 edition (Autumn Budget 2024 forecast, April 2025 implementation):

Change Model, current-income concept Model, annual concept HMRC 2025-26
+£1,000 personal allowance (cost) £8.96bn £8.79bn about £7.7bn to £7.8bn
1p on the basic rate (yield) £6.63bn £6.52bn £6.55bn
  • The model figures are static, for 2025. HMRC's include behaviour, and its table has no £1,000 row: the range comes from its £100 row (£780m) and its 10% row (£9,650m), which HMRC says scale only roughly.
  • Both model figures move toward HMRC's. The basic-rate yield goes from 1.3% above HMRC's figure to 0.5% below it. The allowance cost goes from 15–17% above HMRC's range to 13–15% above it.
  • The engine's income tax on current income falls 1.3%, to £321.0bn, which is below OBR's £325.1bn by construction. The changelog says so, and the same sentence is drafted for the release notes of the first release built from this code.

13. Taken as a sentence. The annual simulation's docstring and the measures' changelog say that pension contributions and salary sacrifice keep their current values, which lowers income_tax_annual a little for a part-year entrant. I did not add a share, because that changes the measurement and would need a new arm.

14. The code is right and the docstring was wrong. A ceased business's SEINCAMT (else PROFIT1) is the amount when it ceased, so it is uprated for the months since, as a leaver's last pay is. Only continuing self-employment (SEINCAM2) is not uprated again. The docstring now says this.

16. Done. The three structural counts use extreme_count. Zero stays exact, so the gate still reads each against 0, and a failing build records "<10" rather than 1 to 9. The gate tests take the banded values, and a new test checks the receipt's bands.

Tests. The 71 engine-free test files that read the stage, the stage gates, the contract or the resolver pass (2,148 passed, 28 skipped). The pin pass comes after the other review items land.

🤖 Generated with Claude Code

juaristi22 added a commit that referenced this pull request Oct 22, 2026
…manifest test

CI on #1344: ruff's import order in spine_build.py, and the shipped-manifest
test's required build stages, which lacked frs_annual_income_spells beside
the regenerated manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
… review)

Vahid's review items 10, 12, 13 and 14. The year the descriptors describe is
a proxy for HMRC's tax year: the 12 months before each FRS interview (April
2024 to March 2025), applied to the engine's 2025 amounts and fitted to HMRC
targets for calendar 2025. HMRC's statistics cover taxpayers, so a leaver
counts only where the year's income makes them liable; the stage docstring,
the measures' comment, the changelog and 15 selector notes no longer say
"anyone paid at any point" or call the window the tax year.

The national-rows changelog says what the concept does to reform costings
on a file built from this code, beside HMRC's 2025-26 ready reckoner
(January 2025). The annual simulation's docstring and the measures'
changelog say that pension contributions and salary sacrifice keep their
current values. The stage docstring now says a ceased business's amount is
uprated for the months since it ceased, as the code does; only continuing
self-employment is not uprated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
Vahid's review item 16. full_year_rows_not_whole, rows_outside_share_bounds
and rows_negative_other_spells are recorded with receipt_counts.extreme_count,
as the other stages record theirs: zero stays exact, so the stage gate still
reads each against 0, and a failing build never records 1 to 9 rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…nt (#1334)

_realized_rate drew claimants until their weight was nearest the pool's
need, but never chose zero: with a need between zero and half the first
unit's weight it returned one claimant where none was nearer. It now returns
the lowest uniform in that case, so no unit draws below it. Pools of 30 or
more sources and the stage gate's 5% tolerance kept the effect small
(Vahid's review of #1344, item 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…rking-age HB (#1334)

uc_take_up drew any entitled, non-reporting unit the engine did not move at
the legacy closure, including units reporting Housing Benefit or another
legacy benefit. policyengine-uk ends a kept legacy award once the unit claims
UC, so the draw moved working-age Housing Benefit onto UC through take-up
rather than migration, with no row checking it. On the 2024-25 spines at
design weights over Great Britain, the working-age units reporting Housing
Benefit that keep it fell by about two fifths with the redraw, already below
DWP's 2025 caseload before it.

A unit reporting a legacy benefit the engine leaves in place is no longer
drawn; it counts in the pool's undrawn claims at its stored flag. The receipt
gains working_age_housing_benefit: the reporting units and those keeping the
award before and after the redraw, and those drawn onto UC, as weighted
figures only where ten or more source households carry them (Vahid's review
of #1344, item 7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…ut fix 3 (pin pass)

The national and local compile-parity receipts were regenerated on the #1344 head without fix 3 (main 954257d plus the #1334 and annual income commits) and do not move; the release input coverage manifest and the charter-H2 spine fixture do. The feed stays abf0921.

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks again. This covers items 1–8, 11 and 15. The branch is force-pushed to 8b1e97fe3 with María's go-ahead.

2, and 3–6 and 11: fix 3 is dropped (María, 22 October). You were right about the comparator, and checking it changed more than the wording.

  • FRS 2024-25 and HBAI FYE 2025 both take Attendance Allowance (through the General Matching Service) and Housing Benefit (through SHBE) from administrative data, for about 95% of respondents. A sampled pensioner who reports neither almost certainly receives neither.
  • The FRS's shortfall against DWP's AA caseload (about 20% in DWP's own comparison) comes mostly from people the survey does not reach: care-home residents, caseload growth since the survey year, and coverage or weighting.
  • So imputing receipt to sampled non-recipients added income where there is none, and that is what moved pensioner poverty away from HBAI.

The AA imputation and the pension-age HB draw are out of this PR, with their gates and resources, and the spine has 48 stages. The calibration's AA and HB rows handle the gap by reweighting, as before.

Items 3–6 and 11 concern fix 3's code:

  • Items 4, 5 and 11 are done on a parked branch, uk-1344-review-fix23, for a coverage-based redesign after the Budget.
  • Item 6 is confirmed: FYE 2025 pensioner HB caseload take-up is 83% (80% to 86%). It goes with that redesign.
  • Item 3's universe point, aligning the rows' universe with the FRS, is where that redesign starts.

7. Done (dbb5c6d4b).

15. Done (4d8378000). A pool now draws no claimant when none is nearer DWP's count than the first.

1. Agreed. María merges #1333 first. I then rebase this PR, drop 7aba9ac6c or reduce it to what #1333 does not cover, and the "no withholding" caveat goes.

8. Agreed. After that rebase I run one spine and one national arm on the merged code, on policyengine-uk 2.145.0 throughout, and post the results here before merge.

Tests on 8b1e97fe3: the pin pass's touched tests pass (1,228 engine-free and 76 engine), and ci_test_plan verify is ok. The PR body takes all the corrections in one edit.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my second review, at 8b1e97fe3.

Verdict. Dropping fix 3 was the right call, and the annual-income answers are good. I checked the ready-reckoner figures against HMRC's January 2025 bulletin, and they hold. Items 7 and 15 are fixed in code, and the new test covers 15. But the branch can't merge as it stands, for three reasons:

I also owe you a correction on item 1.

What I ran

  • Fresh worktree at 8b1e97fe3, uv sync --all-packages --locked --extra uk. That gives policyengine-uk 2.134.4, because the branch is still on the pre-Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 lock.
  • git range-diff from round 1's 16a2cb836 to 8b1e97fe3. The four fix 3 commits and the AA/HB receipt-band commit are gone. 7aba9ac6c is patch-identical to 9d468f895. The annual-income commits are unchanged apart from the stage count (47 to 48). There are seven new commits.
  • The 21 test files the PR touches: 990 passed, 2 skipped, 0 failed. tools/ci_test_plan.py verify: ok (engine-uk=56, integration-uk=1). CI at 8b1e97fe3: engine-uk, integration-uk, lint, select-countries and wheels are green. engine-free and engine-us were still pending when I looked.
  • git merge-tree against main at 8b0224e06 (Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 merged): 8 conflicted files. They are calibration_measure_exclusions.json, support_floor.py and its test, sources.yaml, the coverage manifest, contract.py and its test, and uk_spine.json. Against UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338 at 3bb28f8f5: 9 conflicts, mechanical as before.
  • HMRC "Direct effects of illustrative tax changes": the January 2025 bulletin (2025-26 to 2027-28, April 2025 implementation), plus the June 2024 and January 2024 tables for comparison.
  • One aggregate check on the a858c100 release file (microcosm_uk_2024_25.h5, read offline from the local cache with policyengine-uk 2.134.4). For each family type, I counted the distinct household_source_id values with a positive weight in Great Britain whose 2025 UC is above £30,000 a year. Only support bands are reported here. I used the engine's family_type as a proxy for uc_calibration_administrative_family_type. This is the current release, not a build of this branch.
  • Not run: no spine or national build of this branch, and no UKDS data.

Generated data versus code. About 56.0k of the 62.1k added lines are the vendored DWP UC award-band facts. The AA caseload file left with fix 3. I re-read every new code commit in full. I re-checked the facts file's top-band means by family type (item 17). I did not re-read the regenerated fixtures, manifest or source_stages.json beyond the stage count.

Round 1 status

  1. must, open. Agreed in your reply, but not done yet: Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 merged at 12:11, after the push at 11:35. Correction from me: round 1 said Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 retires the twelve per-row payment-band entries. It doesn't. It retires the eleven benefit-cap band entries and the two HMRC £500k to £1m cells. Main keeps the same twelve UC entries this PR keeps. That makes the fix simpler. Main already has floor 1, the UK_SUPPORT_FLOOR_VALUE check and the twelve entries, so 7aba9ac6c reduces to nothing. Please drop it whole, including changelog.d/1334-uc-support-floor-rebind.changed.md, whose "is deferred" sentence is now wrong. Then take main's side of the exclusions block, support_floor.py and its test.

  2. must, partly resolved. Dropping fix 3 resolves the substance, and your account of it matches the sources. The PR title still says "impute pensioners' Attendance Allowance and Housing Benefit". The body is unchanged since round 1:

    • fix 3's sections and gates;
    • "HBAI uses the FRS-reported amounts, which under-report both";
    • the AA caseload lines;
    • "The national line has no withholding".

    The body is what a merge records, so please make that edit before merge, not after.

  3. to 6. and 11. Moot here. Fix 3 is out. The take-up contract no longer touches HB, and would_claim_housing_benefit is no longer stored. Parking 4, 5 and 11 and taking 3 and 6 into the redesign is fine.

  4. should, resolved in code. dbb5c6d4b keeps units with a legacy benefit the engine leaves in place out of the draw, and counts them in F at their stored flag. That is the change I suggested. The receipt's working_age_housing_benefit block reports weighted figures only over ten or more sources, so it is disclosure-safe. One gap: you say working-age HB kept is already below DWP's 2025 caseload before the redraw, and call that a separate follow-up. Please file it and link it here, so the gap isn't left only in a comment. Note that the receipt's reporting_units excludes units the engine moves, so it isn't the figure to compare with DWP's 660,775 and 370,372 claims.

  5. should, open (agreed). All the measurement tables in the body come from builds that ran fix 3. That includes the poverty, Gini, AA and costing figures. They need the rerun on the rebased code on 2.145.0 before they can be read.

  6. Resolved. The split table answers it. The candidate wins on the 515 rows that aren't rebound (0.0030 against 0.3504), so rule 1 doesn't rest on the concept change. I can't check the subset losses without the receipt, so I'm taking them as given.

  7. Resolved. The stage docstring, the three changelog fragments and the selector notes now call the year a proxy for the tax year. "Anyone paid at any point" is gone.

  8. Resolved, and the figures check out. The January 2025 bulletin gives, for 2025-26: 1p on the basic rate £6,550m, a £100 personal allowance rise £780m, a 10% rise £9,650m. It says "the illustrative figures account for taxpayers' behavioural responses", as the changelog says. The June 2025 edition starts at 2026-27. Your £1,000 range (£7.7bn to £7.8bn) follows from those two rows. One tiny point in the reply, not the changelog: £6.63bn is 1.2% above £6.55bn, not 1.3%.

  9. Resolved as a sentence in the docstring and changelog.

  10. Resolved. The docstring now matches the code, and the reason (a ceased business's amount is as at when it ceased) is right.

  11. Resolved. _realized_rate returns the lowest uniform when zero is nearer, and the test pins both sides.

  12. Resolved. The three structural counts use extreme_count, and a test checks the bands.

New findings

17. must (disclosure): the UC family total less its published bands gives back the unbound top band, and on the current release that band rests on fewer than ten households for single people.

This is round 3 item 15 of #1333, now with data. #1333 merged without it.

  • Each family total, for example dwp.uc.households_single_no_children, counts every Great Britain unit with UC above zero, by uc_calibration_administrative_family_type. The bound bands stop at £2,500 a month (band_upper_bound: 2500). Total less bands therefore equals the withheld bands plus the units above £2,500 a month.
  • uk_withheld_row_sets only asks whether published rows reproduce a withheld combination. The top band isn't a row, so the check never sees it. When no band in a family is thin, the check returns early, and total less bands is the top band alone.
  • On the a858c100 release, the top band rests on fewer than ten source households for single people without children. It has none for childless couples, and 30 or more for both family types with children.
  • This PR makes it more likely, not less. uc_take_up targets the top band as its 26th band. DWP's April to December 2025 means are about 3,600 single and 1,600 childless-couple households above £2,500. At FRS weights that is a handful of source households each. So the draw tends to put one to a few units into each childless top band, where the release has fewer than ten or none.

Two ways to fix it:

  • (a) Bind the top band too, as a row. It is already a DWP fact in both the registry and the stage. The per-row thin rule would then withhold it, and Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333's span check would see that total less bands gives it back, and withhold one more row.
  • (b) Add the family residual check from round 3: for each family with a published total, withhold one more member when total less its published members rests on 1 to 9 source households.

Either fix can land on main first as its own PR, ahead of this rebase. It must be in before the first release built from this code. Please also add a toy test with a thin top band and no thin bound band.

18. should: the merged floor text now contradicts this PR's changelog. This follows from item 1. If 7aba9ac6c survives the rebase in any form, the changelog and the support_floor.py docstring would still say that publishing thin rows' figures "is deferred". Main says they are withheld. Dropping the commit whole removes the contradiction.

Merge order and overlaps

What looks right

  • Fix 3 came out cleanly. No stage, gate, resource, contract row or HB flag is left behind, and the stage count, docs, fixture and gate pins agree at 48.
  • The legacy-kept exclusion uses the same combination and moved arrays as the closure draw, so the two stages agree on who keeps a legacy benefit.
  • The ready-reckoner comparison is set out well. It names the edition, says HMRC includes behaviour, and says how the £1,000 range is built from rows that scale only roughly.

🤖 Generated with Claude Code

juaristi22 added a commit that referenced this pull request Oct 22, 2026
…manifest test

CI on #1344: ruff's import order in spine_build.py, and the shipped-manifest
test's required build stages, which lacked frs_annual_income_spells beside
the regenerated manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
… review)

Vahid's review items 10, 12, 13 and 14. The year the descriptors describe is
a proxy for HMRC's tax year: the 12 months before each FRS interview (April
2024 to March 2025), applied to the engine's 2025 amounts and fitted to HMRC
targets for calendar 2025. HMRC's statistics cover taxpayers, so a leaver
counts only where the year's income makes them liable; the stage docstring,
the measures' comment, the changelog and 15 selector notes no longer say
"anyone paid at any point" or call the window the tax year.

The national-rows changelog says what the concept does to reform costings
on a file built from this code, beside HMRC's 2025-26 ready reckoner
(January 2025). The annual simulation's docstring and the measures'
changelog say that pension contributions and salary sacrifice keep their
current values. The stage docstring now says a ceased business's amount is
uprated for the months since it ceased, as the code does; only continuing
self-employment is not uprated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
Vahid's review item 16. full_year_rows_not_whole, rows_outside_share_bounds
and rows_negative_other_spells are recorded with receipt_counts.extreme_count,
as the other stages record theirs: zero stays exact, so the stage gate still
reads each against 0, and a failing build never records 1 to 9 rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…nt (#1334)

_realized_rate drew claimants until their weight was nearest the pool's
need, but never chose zero: with a need between zero and half the first
unit's weight it returned one claimant where none was nearer. It now returns
the lowest uniform in that case, so no unit draws below it. Pools of 30 or
more sources and the stage gate's 5% tolerance kept the effect small
(Vahid's review of #1344, item 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…rking-age HB (#1334)

uc_take_up drew any entitled, non-reporting unit the engine did not move at
the legacy closure, including units reporting Housing Benefit or another
legacy benefit. policyengine-uk ends a kept legacy award once the unit claims
UC, so the draw moved working-age Housing Benefit onto UC through take-up
rather than migration, with no row checking it. On the 2024-25 spines at
design weights over Great Britain, the working-age units reporting Housing
Benefit that keep it fell by about two fifths with the redraw, already below
DWP's 2025 caseload before it.

A unit reporting a legacy benefit the engine leaves in place is no longer
drawn; it counts in the pool's undrawn claims at its stored flag. The receipt
gains working_age_housing_benefit: the reporting units and those keeping the
award before and after the redraw, and those drawn onto UC, as weighted
figures only where ten or more source households carry them (Vahid's review
of #1344, item 7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
uk_withheld_row_sets only asked whether published rows give back a
combination of withheld rows. A total less its published members gives
back whatever they leave out, which need not be a row: the UC family
total less its bound bands gives back the unbound band above GBP 2,500 a
month. When no bound band was thin the check returned early and never
looked.

A residual check now runs before the span check and again after each
row the span check withholds. A total's members restrict it to some of
its units (a share of its entry on each household, all of it on at
least half); members whose shares summed pass the total's stand in
different groups, so bands and regions of one total are checked apart,
and two bands sharing a household with two benefit units stay together.
While a total less a group rests on one to nine source households, the
group's member with the fewest carriers is withheld.

Toy tests: a thin top band with no thin bound band, a top band on no
household, a household in two bands, and bands beside regions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
…in after #1333 (pin pass)

The national and local compile-parity receipts were regenerated on the #1344 head rebased onto main 8b0224e after #1333, on policyengine-uk 2.145.0, and do not move; the release input coverage manifest and the charter-H2 spine fixture do. The feed stays abf0921.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 22, 2026
… item 17)

Four adversarial reviews of the first residual check (3ed6827) found
published combinations it missed: a constant multiple of a withheld
part, income components the half rule dropped, two overlapping parts
less their total, partitions the greedy groups never formed, a smaller
set inside an over-summing group, and whole-pound components. They also
found a call cost tens of seconds and gigabytes on the national problem.

A total's parts are now every published row inside it. Households whose
parts hold the same shares form an atom (an exact hash of the shares),
and every combination of the total and its parts takes one value on an
atom, so an atom resting on ten or more source households lies in no
thin rest. A total is clear first when every small atom's column lies
within a tenth of the rounding share of the big atoms' span (singular
vectors; as a unit column, row-scaled, and summed as stored). Otherwise
the check takes the total less any set of members whose shares stay
within one (an exact branch-and-bound over atoms, with a constant-share
part also at share one, and counts beside an amount searched last), less
any one part, and less any two parts. Past a node or work cap a total is
grouped greedily, with a warning. One rule settles a share at the
rounding share of one.

On the national problem a call takes about 10 s and withholds the same
rows as the version before. Tests cover each class the reviews found.

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks. Round 2 is done in code on uk-annual-income at 811f34668: the rebase was force-pushed with a lease from 8b1e97fe3 to ed095db3b, and the item 17 commit follows it.

Rebase (items 1 and 18). The branch now sits on main at 8b0224e06.

  • 7aba9ac6c is dropped whole, with changelog.d/1334-uc-support-floor-rebind.changed.md.
  • The exclusions block, support_floor.py and its test are main's, unchanged, so the "is deferred" text is gone.
  • 2b21cc427 recomputes the gate-battery pins on main.
  • ed095db3b is the pin pass on policyengine-uk 2.145.0. It regenerates the coverage manifest and the H2 spine fixture. The national and local compile-parity receipts were regenerated and do not move.
  • Tests: 77 engine tests and 1,266 engine-free tests (the touched and pin-reading files), with 0 failures. tools/ci_test_plan.py verify is ok.

Item 17 (3ed68272e, then 811f34668). I took (b), as a general check rather than one for UC only. Four adversarial reviews of the first version (3ed68272e) and its successors found classes it still missed, so the final _ResidualCheck in uk_runtime/diagnostics.py works like this:

  • A total's parts are the published rows inside it. Households whose parts hold the same shares form an atom, and every combination of the total and its parts takes one value across an atom. So its rest is a union of atoms, and an atom that rests on ten or more source households can lie in no thin rest.
  • A test runs first. When every small atom's share column lies within a tenth of the rounding share of the span of the atoms on ten or more households (singular vectors; as a unit column, with each row scaled, and summed over its stored entries), no combination of the total and its parts with weights between minus one and one can be thin, and the total is clear. This clears the UC family totals whose cells all rest on ten or more.
  • Otherwise it searches three classes: the total less any set of members whose shares stay within one (an exact branch-and-bound over atoms, where a part of constant share also counts at share one, so a multiple of a withheld part is caught); the total less any one part; and the total less any two parts, which gives back two overlapping parts' overlap.
  • While one of these rests on 1 to 9 source households, the part with the fewest carriers among those it subtracts is withheld, and the check runs again. It runs before Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333's check of withheld rows and on each of that check's passes.
  • The search has node and work caps. A total past them is searched over greedy groups instead, and a warning names it. The UK population total, with its hundreds of person and household counts, is the one that does this.
  • Not covered, and said so in the docstring: a total that is no row (a sum of published regions less national bands), a part reaching outside its total, and other weightings on a total the first test does not clear.

Tests: your toy (a thin top band, no thin bound band) and 24 more, one or more for each class the reviews found: a scaled part, monthly bands under a yearly total, two-unit households, income components with a count beside them, overlapping parts, a smaller set inside an over-summing group, bands crossing larger regions, whole-pound components, a negative constant share, three components with no thin pair, a part above its total on one household, a missing source id, nested partitions, near-equal shares, an outlier household beside a gap and on it, a small shortfall beside many bands, and the rounding tie. Most fail on the version before them; the others pin paths no other test reaches. On a national problem a call takes about 10 s. A disclosure-control item from this work went to María separately.

Item 7. Filed as #1352. It uses DWP's published caseloads. It says the model figure to compare is every working-age unit with engine Housing Benefit, not the receipt's reporting_units, as you noted.

Item 8. María has since ruled that #1338 merges first, on policyengine-uk 2.147.2. So the measurement for the body will be one arm of this PR, rebased onto #1338 and re-pinned to 2.147.2, against #1338's own national build as the control, all on one engine. An interim pair on 2.145.0 (this head against main after #1333 on #1333's own spine) is done, and it is consistent with the earlier arms. The body takes the 2.147.2 numbers.

Item 2. The title and body edit comes with the 2.147.2 results, before merge, in one edit.

Item 12. Thanks. £6.63bn is 1.2% above £6.55bn, not 1.3%. The body draft has the correction. The 1.3% in the changelog is a different figure: the engine's income tax on current income, £325.1bn to £321.0bn. That figure, and the costing figures in the same fragment, come from the old arm, so they are refreshed from the 2.147.2 build.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my third review, at 811f34668.

Verdict. The code side of round 2 is done. The rebase is clean, the floor commit is gone, #1352 is filed, and item 17 is fixed: the toy case from round 2 withholds nothing on main and withholds the next band on this head. I have no new must. The branch still can't merge for two reasons you have already scheduled: the title and body (item 2) and the 2.147.2 measurement (item 8). That leaves one timing question, below. #1338 now merges first. If a release is cut from #1338 before this PR lands, it ships without the residual check, and the thin single top band I measured on a858c100 does not need this PR's take-up stage to exist.

What I ran

  • Fresh worktree at 811f34668, uv sync --all-packages --locked --extra uk: policyengine-uk 2.145.0.
  • git range-diff from 8b1e97fe3 (round 2) to ed095db3b (the rebased head before item 17). Eleven commits are patch-identical. d27180855 differs only in pins and in context lines from main. 7aba9ac6c is gone. The two re-pin commits, 2b21cc427 and ed095db3b, are new, and so are the two item 17 commits.
  • The 21 test files the PR touches: 1,048 tests, 1,046 passed, 2 skipped, 0 failed. test_uk_diagnostics.py alone: all pass. tools/ci_test_plan.py verify: ok (engine-uk=56, integration-uk=1). CI at 811f34668: all ten checks green.
  • Round 2's toy case (a total of 30, bands of 15 and 12, a top band of 3 that no row carries) against uk_withheld_row_sets from main at 8b0224e06: it withholds nothing. On this head it withholds band_b.
  • Three probes of my own on synthetic data (household ids only, no PE data):
    • P1. A UC-like layout: four family totals of 25 bands each, an all-UC total, and an overlapping LCWRA-style count. The top band rests on 3 households for single people, none for childless couples and 30 for the other two families. It withholds exactly one single band, in 0.02 s. With the single top band at 12, it withholds nothing.
    • P2. The same layout with amounts instead of counts. It withholds one single band.
    • P3. The gap the docstring names: regional totals that are rows, with national bands under their sum and no national total row. As documented, nothing is withheld.
  • git merge-tree against UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338 at 829c1bc26: 14 conflicted files (item 21).
  • Not run: no spine or national build, and no PE or UKDS data this round. I did not re-read the vendored facts, the fixtures or the regenerated manifest.

Round 2 status

  1. must, resolved. The branch sits on 8b0224e06, 7aba9ac6c is dropped with its changelog fragment, and git diff 8b0224e06 HEAD is empty on support_floor.py, its test and calibration_measure_exclusions.json.
  2. must, open (scheduled). The title still says "impute pensioners' Attendance Allowance and Housing Benefit". The body still says it is "rebased onto 954257d85", still carries fix 3's sections and "HBAI uses the FRS-reported amounts", and still says "The national line has no withholding", which is now wrong on this head. One edit with the 2.147.2 numbers before merge is fine, as long as it does come before merge.
  3. should, resolved. UK spine: the early flat UC draw moves working-age Housing Benefit reporters onto UC #1352 is filed, and it names the right model figure to compare.
  4. should, open (by your ruling). It waits on UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338 and the 2.147.2 arm.
  5. Resolved. Thanks for separating the two percentages. The 1.3% in the changelog is the engine's current-income tax and comes from the old arm, so it is right to refresh it with the rest.
  6. must, resolved in code. _ResidualCheck catches the case I raised and the variants I tried (P1, P2). The documented gaps are real but narrow. On the national line, the only UC household rows are national ones, so I couldn't build a UC case for P3 from the current targets. One ask on evidence remains (item 19).
  7. should, resolved. It went with item 1.

New findings

19. should: show the residual check biting on real weights. Your reply says that on the national problem the final version "withholds the same rows as the version before". That compares 811f34668 with 3ed68272e, not with main. Round 2 found fewer than ten source households in the single, no-children top band on a858c100. So on the interim 2.145.0 arm, the residual check should withhold at least one more single band than main does. Please give aggregates only: how many rows the residual check withholds on that arm, beyond main's thin and span sets, and which families they sit in. If the answer is none, either the band moved or the check doesn't reach it, and we need to know which.

20. should (timing): land item 17 on main ahead of any release from #1338. 3ed68272e and 811f34668 touch only diagnostics.py, its test and one changelog fragment, with no pins. A git merge-tree of the pair onto main at 54b514f92 has no conflict. Round 2 asked for the fix before the first release built from this code. Since #1338 now merges first and could become the promoted pre-Budget release, the safer version is: before any release from main. If #1338 and this PR go out in one release, this doesn't apply.

21. should: the #1338 rebase has a code conflict, not only pins. frs_take_up.py, uk_take_up_signal_gate:

Please keep #1338's mask and its under-2 branch, and drop only the UC branch. Then either keep the keyword or update those three calls. Taking this PR's side whole would measure the under-2 flag over every person, against an under-2 rate. measure_simulation.py conflicts only in its imports. The other twelve files are pins, docs and fixtures.

22. nit: file the residual check's documented gaps. They are a total that is no row, a part reaching outside its total, other weights on a total the span test does not clear, and the local line. At the moment they live only in the _ResidualCheck docstring and the changelog. As with item 7, an issue keeps them from being lost when the local-line ruling comes up.

23. nit: the greedy-cap warning. By your account the UK population total passes the cap on every national build, so the warning fires every time. Nothing treats warnings as errors, so it is harmless, but it will become noise. Consider recording capped totals in the diagnostics payload instead, so a reader can see which totals were searched greedily.

Merge order

What looks right

  • The span test is a sound shortcut. A thin rest is a union of small atoms, so when the small atoms lie in the span of the large ones, no bounded combination can isolate them. It clears the dense UC totals without a search.
  • Withholding only removes combinations, so a total once clean stays clean, and a greedily searched total is searched again when one of its members goes. Both are stated and pinned by tests.
  • The tests are built around the classes of miss, and most fail on the version before them. That is the right way to grow a disclosure check.

🤖 Generated with Claude Code

juaristi22 and others added 7 commits October 23, 2026 12:14
…rking-age HB (#1334)

uc_take_up drew any entitled, non-reporting unit the engine did not move at
the legacy closure, including units reporting Housing Benefit or another
legacy benefit. policyengine-uk ends a kept legacy award once the unit claims
UC, so the draw moved working-age Housing Benefit onto UC through take-up
rather than migration, with no row checking it. On the 2024-25 spines at
design weights over Great Britain, the working-age units reporting Housing
Benefit that keep it fell by about two fifths with the redraw, already below
DWP's 2025 caseload before it.

A unit reporting a legacy benefit the engine leaves in place is no longer
drawn; it counts in the pool's undrawn claims at its stored flag. The receipt
gains working_age_housing_benefit: the reporting units and those keeping the
award before and after the redraw, and those drawn onto UC, as weighted
figures only where ten or more source households carry them (Vahid's review
of #1344, item 7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's 47 stages (with #1338's childcare_copy_take_up) plus uc_take_up and frs_annual_income_spells. The gate-battery pins are regenerated by the pin pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture tool builds the annual income stage and writes the adult and job
columns it reads: every adult works and is paid, some started their job
within the year, some retired from one, and some run a business under a year
old. These came with the annual income pin pass; without fix 3, the care
table and the Attendance Allowance rows stay out, and the tool counts 48
stages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uk_withheld_row_sets only asked whether published rows give back a
combination of withheld rows. A total less its published members gives
back whatever they leave out, which need not be a row: the UC family
total less its bound bands gives back the unbound band above GBP 2,500 a
month. When no bound band was thin the check returned early and never
looked.

A residual check now runs before the span check and again after each
row the span check withholds. A total's members restrict it to some of
its units (a share of its entry on each household, all of it on at
least half); members whose shares summed pass the total's stand in
different groups, so bands and regions of one total are checked apart,
and two bands sharing a household with two benefit units stay together.
While a total less a group rests on one to nine source households, the
group's member with the fewest carriers is withheld.

Toy tests: a thin top band with no thin bound band, a top band on no
household, a household in two bands, and bands beside regions.

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

Four adversarial reviews of the first residual check (3ed6827) found
published combinations it missed: a constant multiple of a withheld
part, income components the half rule dropped, two overlapping parts
less their total, partitions the greedy groups never formed, a smaller
set inside an over-summing group, and whole-pound components. They also
found a call cost tens of seconds and gigabytes on the national problem.

A total's parts are now every published row inside it. Households whose
parts hold the same shares form an atom (an exact hash of the shares),
and every combination of the total and its parts takes one value on an
atom, so an atom resting on ten or more source households lies in no
thin rest. A total is clear first when every small atom's column lies
within a tenth of the rounding share of the big atoms' span (singular
vectors; as a unit column, row-scaled, and summed as stored). Otherwise
the check takes the total less any set of members whose shares stay
within one (an exact branch-and-bound over atoms, with a constant-share
part also at share one, and counts beside an amount searched last), less
any one part, and less any two parts. Past a node or work cap a total is
grouped greedily, with a warning. One rule settles a share at the
rounding share of one.

On the national problem a call takes about 10 s and withholds the same
rows as the version before. Tests cover each class the reviews found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main gained childcare_copy_take_up with #1338, so uc_take_up makes 48 stages and frs_annual_income_spells 49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gates.json moves with the uc_take_up and frs_annual_income_spells stage gates, UC leaving the take-up signal gate, and the signal gate's note (under-2 usage now over children of DfE age 0 or 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in after #1338 (pin pass)

The national and local compile-parity receipts were regenerated on the #1344 head rebased onto main 65e5eae after #1338, on policyengine-uk 2.147.2, and do not move; the release input coverage manifest and the charter-H2 spine fixture do. The feed stays abf0921.

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks. Round 3 is addressed on uk-annual-income at e399d21ef, rebased onto main at 65e5eaeb0 after #1338 and force-pushed with a lease from 811f34668.

Item 20. #1363 carries the two item 17 commits on main by themselves, cherry-picked onto 65e5eaeb0 without changes. They touch diagnostics.py, its test and one changelog fragment, with no pins. When #1363 lands first, this PR's copies drop out at the next rebase.

Item 21 (the #1338 rebase). uk_take_up_signal_gate keeps #1338's population mask and its under-2 branch. Only the UC branch and the UC entry in UK_TAKE_UP_SIGNAL_OUTPUTS go, so the under-2 flag is still measured over the children of DfE age 0 or 1 in claiming units, against their age-weighted rate. The population_policy keyword goes with the UC branch, since nothing else in the gate reads it; #1338's under-2 test now calls the gate without it, and its empty-UC-population test is dropped because the gate no longer measures UC. The failure message and the population_units detail are #1338's. measure_simulation.py takes both import blocks and both test blocks. The spine has 49 stages (main's 47, with #1338's childcare_copy_take_up, plus uc_take_up and frs_annual_income_spells), and the counts, fixture tool and changelog fragments say so. As the #1338 author asked, the take-up-signal note in gates.json now says "DfE age 0 or 1"; the gate-battery pins move with it (eab75988e). e399d21ef regenerates the coverage manifest and the H2 spine fixture on policyengine-uk 2.147.2; the national and local compile-parity receipts do not move. CI runs the tests.

Item 19. On the interim 2.145.0 arm, at its final weights, aggregates only:

  • The residual check withholds 8 rows beyond main's thin and span sets, and one of main's span picks is no longer needed. The 8 sit in four families: council-tax stock bands (3), a benefit claimant count (1), State Pension recipients by weekly amount (1), and UC households with four children with their two-child-limit rows (3).
  • It adds no UC payment band. On this arm the single, no-children top band rests on ten or more source households and the childless-couple top band on none, and in all four UC families the total less its published bands rests on ten or more.
  • So the band moved: this PR's take-up stage draws the top band against DWP's counts. The check reaches it when it is thin, as your P1 and the toy tests show.

Items 22 and 23. Filed together as #1364: the four documented gaps (a total that is no row, a part outside its total, other weights on a total the first test does not clear, and the local line), and recording capped totals in the diagnostics payload instead of a warning. The payload change moves the schema-8 JSON Schema, so it is its own change.

Items 2 and 8. The 2.147.2 arm is running now on e399d21ef, against #1338's certified national run as the control. The title and body follow with its numbers, in one edit, before merge.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my fourth review, at e399d21ef.

Verdict. The rebase onto #1338 is clean and item 21 is handled the way I asked: #1338's under-2 mask stays, and only the UC branch goes. Items 19, 22 and 23 are answered. I have no new must on this branch. It still waits on the two things you have scheduled: the 2.147.2 arm (item 8) and the title and body (item 2). The one new should is about item 17 reaching main. #1363 was closed unmerged at 11:32, but your reply at 11:52 still has it landing first. As things stand, the residual check reaches main only when this PR merges. The release published this morning, a133f37a, was cut without it.

What I ran

Round 3 status

  1. must, open (scheduled). The title still names Attendance Allowance and Housing Benefit. The body still says "rebased onto 954257d85" (line 1) and "HBAI uses the FRS-reported amounts" (line 212). It also still says "The national line has no withholding" (line 248), which this head contradicts. You've said one edit with the 2.147.2 numbers comes before merge. That is fine.
  2. should, open. The 2.147.2 arm is running on e399d21ef against UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's certified run.
  3. should, resolved. Thanks for the aggregates. On your 2.145.0 arm, the single top band rests on ten or more, so it has moved. The eight extra rows sit in other families. Dropping one of main's span picks is safe, because the span loop still runs to a fixed point after the residual check.
  4. should, not met, and partly our timing. Neither 829c1bc26 nor main has _ResidualCheck, so a133f37a was published without it. To be fair to you: that attempt started at 07:50 and my round 3, which asked for this, went up at 09:18, while it was building. See item 24 for what follows now.
  5. should, resolved. uk_take_up_signal_gate keeps UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's population mask and its under-2 branch. Only the UC branch, its UK_TAKE_UP_SIGNAL_OUTPUTS entry and the population_policy keyword go. test_take_up_gate_measures_under_two_usage_over_claiming_children is still there (test_uk_take_up_gate.py:186), and the gates.json note says "DfE age 0 or 1".
  6. nit, resolved. Filed as UK withholding: the residual check's documented gaps, and recording capped totals #1364.
  7. nit, resolved. Also in UK withholding: the residual check's documented gaps, and recording capped totals #1364.

New findings

24. should: #1363 is closed, so item 17 now reaches main only with this PR. Please also run the check on a133f37a.

  • UK: withhold the published parts whose total less them gives back a thin cell (#1344 item 17) #1363 was opened at 11:10 and closed unmerged at 11:32, with its CI cancelled. Your reply at 11:52 says it lands first and this PR's copies drop out at the next rebase. Was the closure deliberate? If so, please say so on UK: withhold the published parts whose total less them gives back a thin cell (#1344 item 17) #1363. Any release cut from main before this PR merges will also lack the check.
  • What a133f37a exposes on the route from round 2: in the single, no-children family, the top band above £2,500 a month still rests on fewer than ten source households. The childless-couple top band rests on none, and both family types with children have ten or more. On this release, though, the family total less its published bands rests on ten or more households in all four families. This is because main's withholding already sets aside the upper single and childless-couple bands, and five single bands are not targets at all. So the difference item 17 was about doesn't isolate that band here. I checked only this one combination per family.
  • What I can't check: your item 19 arm shows that the residual check withholds eight rows that main's code publishes. They are council-tax bands, a claimant count, State Pension by weekly amount, and the four-child UC and two-child-limit rows. All of those families are in a133f37a's published targets. Its weights differ from your arm's, so this proves nothing about the release either way. Please run uk_withheld_row_sets from this head on a133f37a's own solved problem and give aggregates. If it withholds anything beyond what the release withholds, those rows' model figures need to come out of its diagnostics before anyone quotes them.

Merge order

What looks right

  • The item 21 resolution is minimal. The UC branch goes because uc_take_up's own stage gate measures UC by band, and the under-2 measurement keeps its own population.
  • Your item 19 answer separates the two possible causes, a moved band versus a check that doesn't reach it, and it says which one applies. That is the evidence I asked for.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks for the fourth review.

Item 24. The closure of #1363 was deliberate: María chose to let item 17 reach main with this PR, not on its own, so any release cut from main before this PR merges lacks the residual check. I ran this head's uk_withheld_row_sets on a133f37a's own solved problem, from the run's H5 and calibration_diagnostics.json, whose sha256 values match the release manifest. The result is a disclosure-control item, and it went to María separately.

Items 2 and 8. The 2.147.2 arm on e399d21ef restarted at 12:24 UTC, reusing its spine; the first attempt stopped with my session. The title and body follow with its numbers, in one edit, before merge.

🤖 Generated with Claude Code

The fragment's figures came from the fixes-2-and-3 arm on 2.134.4. They now compare #1338's certified build with this branch's arm, both on policyengine-uk 2.147.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 marked this pull request as ready for review October 23, 2026 14:38
@juaristi22 juaristi22 changed the title UK #1334: draw UC take-up by award band, impute pensioners' Attendance Allowance and Housing Benefit, bind every supported UC band, and measure the HMRC income rows over the tax year UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year Oct 23, 2026
@juaristi22

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi the item 8 measurement on policyengine-uk 2.147.2 is done, and the title and body now carry it (item 2).

Setup. The arm is this branch at e399d21ef (main 65e5eaeb0 after #1338, 49 spine stages): one spine and one national solve, review date 2026-10-23, signed with a throwaway key (not attested). The control is #1338's own certified national run at 829c1bc26. Both run on 2.147.2, and the dashboard statistics and costings run on 2.147.2 too. b0471dde6 only refreshes the changelog's costing figures from this arm.

Gates and fit (control → this PR)

  • Seam gates 7/7 and terminal gates 7/7 in both.
  • Rows within 10%: 99.09% → 99.25%. On the common rows, the HMRC employment counts for £12,570–15k and £15–20k come inside 10%, and no row leaves.
  • ESS: 6,853 → 7,359. Max/median weight: 2,430 → 2,404. Top-1% weight share: 25.3% → 24.3%.
  • OBR income tax: −0.11% → −0.02%. Employee NICs: +4.07% → +1.94%. Self-employed NICs: −0.08% → +0.10%.

Before the solve (initial estimate against DWP)

UC households Control This PR
single, no children +13.6% −3.5%
couple, no children +90.1% +28.2%
couple with children +55.7% +16.6%
single with children −35.2% −30.0%
  • UC payment bands (82 common): 49 closer, 8 unchanged and 7 further. The target-weighted absolute error falls from 53% to 21%.
  • One row moves away: OBR's UC amount, from −12.9% to −15.4%. Pension Credit is unchanged.

Poverty and inequality, 2024 (dashboard statistics; HBAI FYE25 in the last column)

Measure Control → this PR HBAI
Relative BHC, all 13.7% → 14.3% 15.9%
Relative BHC, children 18.5% → 18.9% 20.5%
Relative BHC, working age 12.0% → 13.2% 14.3%
Relative BHC, pensioners 13.6% → 12.7% 15.9%
Relative AHC, all 17.3% → 17.9% 19.6%
Relative AHC, children 24.5% → 25.3% 27.4%
Relative AHC, pensioners 13.0% → 12.2% 13.9%
Absolute BHC, all 12.1% → 13.6% —
Absolute AHC, all 15.9% → 17.6% —
Gini, BHC 0.299 → 0.305 0.327

2025 moves the same way. Every row moves toward HBAI except pensioners. The pensioner fall comes from the poverty line: on the 2.145.0 pair, absolute pensioner poverty did not move while the relative line fell.

Costings (static, 2025)

  • +£1,000 personal allowance: £8.98bn → £8.81bn (−1.9%). HMRC's ready reckoner implies about £7.7–7.8bn.
  • +1p basic rate: £6.62bn → £6.51bn (−1.7%), now 0.6% below HMRC's £6.55bn.
  • The engine's income tax on current income is £324.7bn → £321.5bn (−1.0%).

Rule 1 against the pinned eFRS: passes.

  • All 1,132 scored rows: 0.0066 against 0.347.
  • Without the 50 rebound declarations (542 rows): 0.0045 against 0.367.

Memory: the national build peaks at 22.7 GB.

🤖 Generated with Claude Code

@juaristi22
juaristi22 merged commit 4cae4e4 into main Oct 23, 2026
4 of 5 checks passed
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my fifth review, at b0471dde6.

Verdict. No must is open. The title and body are fixed (item 2), and the 2.147.2 measurement is in (item 8). Every control figure in your reply matches the published a133f37a diagnostics to the digit, and every HBAI and HMRC comparator matches its source. The fit, gate, Rule 1 and costing arithmetic is consistent. From my side this can merge once CI is green at b0471dde6. The diff since round 4 is one changelog line. I have three shoulds, and none of them blocks the merge: the PR arm's diagnostics can't be read anywhere (25), the residual-check item on a133f37a has no visible outcome yet (item 24), and the personal allowance costing gap needs a follow-up issue (27). The rest are small asks about your reply's numbers.

What I ran

  • Fresh worktree at b0471dde6. git diff e399d21ef..b0471dde6: one file, changelog.d/uk-annual-income-national-rows.changed.md, which restates the costing figures from the 2.147.2 arm. I did not re-run tests, because round 4 ran the 21 touched test files at e399d21ef and nothing else has changed.
  • CI: Tests passed at e399d21ef. At b0471dde6 it has been queued since 14:38 UTC and was still pending when I posted.
  • Control side of item 8: read the published a133f37a calibration_diagnostics.json (sha256 0c975ad0…0637, matching the release manifest; code pin 829c1bc26). Within 10%: 1,203 of 1,214. ESS: 6,852.6. Top-1% weight share: 25.3%. Withheld: 45. OBR income tax: −0.11%. Employee NICs: +4.07%. Self-employed NICs: −0.08%. Initial UC households: +13.6%, +90.1%, +55.7% and −35.2%. Band error, target-weighted over the 68 bands with figures: 53.1%. OBR UC amount before the solve: −12.9%. Pension Credit before the solve: −26.1%. The two HMRC employment counts: −12.8% each. All match your reply.
  • PR side: I looked for the uk-prebudget-r1344c-e399d21ef run on the private release repo and found nothing. Its last commit is 11:23 UTC, when a133f37a became the latest national release. So I could only check this arm's figures for internal consistency.
  • Comparators: HBAI FYE25 summary tables 1.3a, 1.4a, 1.5a and 1.6a and the BHC block of 1.2b. All eight rates and the BHC Gini (32.7) match your table. HMRC's January 2025 bulletin: 1p basic rate £6,550m, £100 personal allowance £780m and 10% personal allowance £9,650m for 2025-26, as you say. That is the right edition, because the June 2025 edition starts at 2026-27.
  • Arithmetic: 1,194/1,203 = 99.25%. 45 + 12 − 1 = 56 withheld, which fits 1,202 common rows. The Rule 1 split recombines exactly: (542 × 0.0045 + 590 × 0.0085) / 1,132 = 0.0066, and the same for the incumbent's 0.3474. 50 rebound declarations fan out to 590 rows. The costing ranges are right too: £8.98bn is 15–17% above £7.7–7.8bn, and £8.81bn is 13–14% above it.
  • #1347 is now on b0471dde6 (head c0578bfaa).
  • Not run: no build or solve, no re-run of your arm or of uk_withheld_row_sets, and no dashboard statistics.

Round 4 status

  1. must, resolved. The title no longer names Attendance Allowance or Housing Benefit. "Rebased onto 954257d85", "HBAI uses the FRS-reported amounts" and "The national line has no withholding" are all gone. The body now states the residual check, the 2.147.2 arm and the merge order.
  2. should, resolved for the control and open for the PR side. The control half is backed by a published artefact and reproduces exactly. The PR half rests on your reply and the changelog only. See 25.
  3. should, not met. Acknowledged, no change. a133f37a was cut without the residual check, and no release from main will have it until this merges.
  4. should, partly.

New findings

25. should (not blocking): put the arm's diagnostics where a reviewer can read them, and label the changelog's figures.

  • Every PR-side number in item 8 comes from a run signed with a throwaway key, under the 20 October ruling for pre-Budget arms. It is not attested and not release-authorized. That is fine as a measurement. Merging code needs no attested artefact, and the first certified cut from main will produce its own diagnostics.
  • But the changelog fragment now gives £324.7bn → £321.5bn, £8.98bn → £8.81bn and £6.62bn → £6.51bn as facts about "files built from this code". Please do either or both of these:
    • stage the arm's calibration_diagnostics.json on the private repo, marked as not a release, so the PR side can be checked like the control side;
    • say in the fragment that the figures come from a pre-Budget arm at e399d21ef, to be confirmed on the first certified build.
  • If the certified build differs materially, the fragment should change with it.

26. nit: "by construction" in the changelog. The calibration fits OBR's total on annual income. That the current-income total lands below it is a result on this build, not something the method guarantees: part-year entrants pull annual income below current, and leavers push it above. "Now below OBR's receipts on this build" would be accurate.

27. should, as a follow-up issue rather than here: the personal allowance costing is 13–14% above HMRC's while the 1p yield is within 1%.

  • This PR moves the allowance cost the right way, and the gap was there before it (15–17%). So it doesn't block.
  • Taken together, the two figures point away from the basic-rate base and toward the number of taxpayers at each marginal rate, or toward the taper above £100,000. A 1p change reads the base, while an allowance rise is worth 20p, 40p or 45p per pound to each taxpayer, depending on their marginal rate.
  • A useful check is the model's taxpayers by marginal rate against HMRC's Income Tax liabilities statistics.
  • One caveat for that issue: note 1 of the January 2025 bulletin says its figures are the exchequer impact on the UK government, including block-grant effects, and exclude the devolved elements for Scotland and Wales. So the scope of the comparison needs pinning down before the gap is treated as a model error.

28. nit: the UC band count doesn't add up. "82 common: 49 closer, 8 unchanged and 7 further" sums to 64. I take it the other 18 have no published figure in one build or both (the control has 15 of its 83 bands withheld). Please say so, because the 53% → 21% figure is over a subset.

29. should (small): give this arm's pensioner evidence, not the 2.145.0 pair's.

  • Your explanation is plausible, and this arm's own figures support it. Absolute BHC poverty rises 1.5pp while relative rises 0.6pp, so the relative line fell, and pensioner incomes bunch near it.
  • But the direct evidence, a flat absolute pensioner rate, is from the interim pair. Please give this arm's absolute BHC and AHC pensioner rates. If the dashboard statistics carry it, please also give the equivalised BHC median under the control and this PR, beside HBAI's £719.48 a week (table 1.2b, 2024-25 prices). That shows whether the line moved toward HBAI's or away from it.
  • Either way, the remaining pensioner gap to HBAI (−3.2pp BHC in 2024) is then on the income side. It should be recorded on UK national calibration moves weight away from low-income households: reconcile the HMRC low-band rows, model UC take-up by award size, impute pensioner benefit receipt (pre-Budget) #1334 with the coverage redesign, so it isn't read as a weighting result.

30. nit: the OBR UC amount after the solve. Only the before-solve figure (−12.9% → −15.4%) is given. In the control it fits at −0.15% after the solve. Please add this arm's final figure, and say what drives the pre-solve move, so that the regression line in the body is complete.

Merge order

What looks right

  • The reply gives a concrete control (829c1bc26, on the same engine version), and it reproduces exactly from the published file. That is what item 8 needed.
  • The body states its regressions, rather than leaving them for a reviewer to find: pensioner poverty, the pre-solve UC amount, Pension Credit, the withheld-row count and the local concept split.
  • The Rule 1 split shows that the pass does not rest on the 590 rows scored on different concepts (0.0045 against 0.3674 on the rest).

🤖 Generated with Claude Code

juaristi22 added a commit that referenced this pull request Oct 23, 2026
The changelog fragment's 2025 income tax and costing figures come from
the e399d21 arm, signed with a throwaway key, against #1338's certified
build. Say so, and say that current income lands below OBR's receipts on
this build rather than by construction (Vahid's items 25 and 26 on #1344).

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks. Round 5 is addressed after the merge: the changelog items are on #1347, and the rest is below or in follow-ups.

Items 25 and 26. e6ac8b157 on #1347 edits uk-annual-income-national-rows.changed.md. It says the 2025 income tax and costing figures come from the pre-Budget arm at e399d21ef, signed with a throwaway key and not attested, against 829c1bc26, both on 2.147.2. It says the first certified build from main confirms them, and the note changes with that build if they move materially. "By construction" is now "on this build", with the reason: part-year entrants pull annual income below current income, and leavers push it above. I did not stage the arm's diagnostics on the private release repo. The first certified cut from main produces its own.

Item 27. Filed as #1370, with the marginal-rate check against the Income Tax liabilities statistics, the taper zone, the reform definition, and the scope caveat from note 1 of the bulletin.

Item 28. Of the 82 common bands, 64 have a figure in both builds. The other 18 are withheld in one build or both: 14 in both and 4 in this PR only. The control's 15 withheld bands are those 14 plus one band that this PR does not have. The 53% → 21% figure is over the 64.

Item 29. This arm's pensioner rates, control → this PR:

2024 2025
Absolute BHC 11.4% → 11.8% 11.0% → 11.4%
Absolute AHC 11.4% → 11.6% 11.3% → 11.5%

The absolute rates rise a little while the relative rates fall, so the line moved. The equivalised BHC median falls under this PR and moves toward HBAI's £719.48 a week. I do not give the medians themselves, because a weighted median without interpolation is one household's value. The remaining gap to HBAI is recorded on #1334 (comment), with the pensioner benefit receipt and the coverage redesign.

Item 30. After the solve, OBR's UC amount is at −0.42% (control −0.15%). Before the solve, it moves from −12.9% to −15.4%. The new uc_take_up stage draws claims to DWP's caseloads by family type and £100 award band, and it does not fit the amount. The caseloads come down toward DWP's in three of the four families (the table in the item 8 reply), and the amount falls with them. The control was already 12.9% short on the amount while it over-counted households, so the model's award per claimant sits below OBR's outturn in both builds. The solve fits the amount in both.

Item 24. #1363 now has a note (comment): the closure was on purpose, and #1344 put the residual check on main. The item from the check on a133f37a is tracked privately with María. a133f37a will not be amended. The next national release from main supersedes it.

🤖 Generated with Claude Code

@juaristi22

juaristi22 commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi An update to item 24 in the reply above.

Since that reply, María has ruled on the disclosure-control item: a133f37a's diagnostics and evidence files are only in policyengine/populace-uk-private, which only UKDS-licensed users can access, and sharing them within that group isn't publication. The item is closed. Those files won't be amended, and nothing more is tracked for them. The next national release, built on #1347's head, still replaces a133f37a as latest in the normal course, and it carries this PR's residual withholding.

Anything quoted from release files outside the licensed group goes through the usual output checks, as before.

🤖 Generated with Claude Code

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.

2 participants