Skip to content

One consistent UK target surface: enforced cross-grain reconciliation, declared uprating holds, nation household controls and the 942a1fa re-pin (#1123 PR A) - #1198

Merged
juaristi22 merged 55 commits into
mainfrom
uk-target-surface-1123
Oct 12, 2026
Merged

juaristi22 merged 55 commits into
mainfrom
uk-target-surface-1123

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1123 (PR A: items 1, 2, 6 and 7; private rent is PR B). Addresses #104.

Why

The K=25 audit found that most local misses surviving the dense solve were on the target side. Local rows were never uprated, 509 national rows were held at an older period with no declared index, nothing refused an undeclared hold, and reconciliation across grains was opt-in. This PR makes the UK target surface one consistent, enforced surface.

What changes

Value moves (receipts in docs/evidence/uk-target-surface-1123/)

The branch is rebased on main at 51a263726. Before is that commit with only the receipt tool; after is cd39d037e, run on a clean tree. Each receipt records what its working tree adds to the commit it names.

  • National, bound rows: 1,177 to 1,213. 36 added (12 nation household cells, 24 marginal-rate cells), none removed, 552 moved.
    • By this PR's own reconciliation: 360 SPI regional cells (the band bridges) and 24 CGT regional cells.
    • By the re-pin and the household controls: 149 population rows, the 10 household-composition rows, land (3), SLC repayments (3), ISC, public sector employment and DfI.
  • Local: 20,885 to 21,804 cells. 919 added, none removed, 18,811 moved (every age, census-household, tenure and UC cell; the four existing HMRC area targets; private rent through tenure; and 437 council-tax band cells in London, the South West and Scotland, by up to 0.5%, because a deferred authority no longer leaves its share on its neighbours).
  • Reconciliation factors:
    • National band bridges onto the SPI regional cells: 1.002 to 1.057, each inside its declared bound. The gap is the taxpayers Table 3.11 places in no region, concentrated in the lowest band (2.7% of taxpayers and 5.7% of income tax at £12,570 to £15,000). The compile checks that on the values: each factor sits within half a point of Table 3.11's UK row over the regional cells' sum (21 of 24 match to five decimals; the widest gap is 0.19 points).
    • HMRC area cells to the national bands, source agreement: 0.999 to 1.006 at constituency grain and 1.003 to 1.008 at local-authority grain (gate 2%). The gap between the two sides' uprating indices is 0.997 to 1.014 (1.44% on employment counts), each inside its bridge's bound. The factors the cells take are the product, 0.996 to 1.019.
    • Local-authority legs under the constituency totals: 1.018 to 1.021 on employment counts and 1.008 to 1.011 on employment amounts. Torbay and the Isles of Scilly, which HMRC's local-authority tables do not carry, are implied at 2.3% of the South West's employees; they hold 2.6% of its households.
    • Nation household controls over constituency census cells: 0.973 (North East) to 1.038 (London); Scotland 0.989, Wales 0.994, Northern Ireland 0.998.
    • Higher rate area cells to their 2025 rows: 1.19 to 1.40.
    • UC households by area to the GB caseload: 0.921 (0.942 before this PR). This is the paid share, see above.

Verification

  • tools/uk_target_surface_receipt.py --communal runs clean end to end on the pinned feed, on a clean tree at cd39d037e.
  • Every new check passes on the feed: both fan-out gates at both grains, the 24 band-bridge bounds and their explained factors, all 20 subset tests (none skipped), the 44 off-surface cells, no row without a readable period, and the roll-forward base and series checks for Wales and Northern Ireland.
  • Local runs at cd39d037e: the engine-free job's files, 16,693 tests with 6 failures that are local to this machine or to live state (two Orrery parser tests need a node package; one CGT test expects no pe-uk in the engine-free venv; one telemetry test is unstable under parallel workers; one head-to-head scorer test needs Python 3.14.7; one microcosm-data test reads a live Hugging Face pointer that CI skips); engine-uk 180 of 180; lint, tools/ci_test_plan.py verify and the vendored-resource check ok. CI on the previous head b8509c765 was green on every lane, engine-us included.
  • The Make zero-valued calibration targets intentionally nonzero-weighted #104 edit to microcosm.calibrate.solve moves the loader golden vector, the US seed digests, the US spec digest, the US coverage evidence and the calibrate graph parity pins; all are re-pinned as main does for solver edits, and recomputed after each rebase where main moved them too.
  • Not run: the dense re-solve, so the effect on fit is not yet measured. main retired the West Midlands income-tax deferral (UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145), so that cell is bound against the target this PR moves; its fit shows at the next national build.

Departures from the plan

  • No closure gate in gates.json: closure fails closed at compile instead.
  • No council-tax band partition, and the preflight tool is not updated.
  • The A15 census-household uprating step is kept; it now targets the nations' sum and the nation bridge then rescales.

Open decisions (María)

Before merge:

Can follow as issues:

  • Clip self-employment losses in the amount rows to match HMRC.
  • Salary-sacrifice relief growth.
  • The Northern Ireland household method.
  • An edition preference in the resolver, so the land rows resolve at 2023 again.

After merge

  • Re-freeze the scoring register in uk-candidate-eval (the reconciled national values change the registry version).
  • PR B (rent) rebases on this branch.

🤖 Generated with Claude Code

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Independent adversarial review (Claude Code) at 67fc1dd04

Static review only. I read the code and the committed registers at this head and at the merge base aca40a692, and I checked the UC source package in Chronicle at 942a1fa. I ran no tests, no compile and no receipt tool, so every factor below is control ÷ the sum of the committed cells before reconciliation, the same ratio the code takes. Engine CI was still queued when I looked.

Verdict. The core machinery holds up. Cross-grain coverage is complete for the committed contract: 15 multi-grain ids, 28 overlap pairs, none undeclared. The holds cover every lagged national row, and the #104 edit to solve.py changes no numbers. But four things should change before this leaves draft:

  • the UC bridge reconciles two different concepts;
  • the 2% fan-out gate never sees the local-authority grain, which needs 2.1%;
  • the new national band bridges have no tolerance;
  • the signatures the body asks for aren't enforced by any code.

Must fix

  1. UC by area reconciles all-claims cells onto a paid-claims control.

    • National control: dwp.uc.households binds payment_indicator: "Yes" (uk_population_targets.json:14716).
    • Area cells: the selector (:28243) reads universal_credit_households with no payment dimension. The Chronicle package at 942a1fa (dwp/uc_households_by_constituency_january_2025_may_2026) has only the date and PCON24 fields, so it counts all UC households, nil-award claims included. docs/uk-uc-paid-target-contract.md is why the national rows moved to paid claims in the first place.
    • Size: the areas sum to about 6.73m against 6.20m for GB, hence the 0.921. Aligning the windows widened the gap (0.942 → 0.921), which points at concept, not timing.
    • The selector note says the bridge "reconciles like with like"; it doesn't.
    • Effects:
      • The uniform factor assumes the same nil-award share in every constituency.
      • The child bands keep their all-claims shares, so after the bridge they disagree with the national paid rows: +2.8% with no children, −1.5% with one, −3.5% with two and −5.1% with three or more. (The national no-children figure is the total less the child rows.) The solve gets conflicting targets across grains.
      • The coverage check can't see this, because the signatures differ.
    • Fix: extract the area series with Payment Indicator = Yes (UC_Households carries the field).
  2. The 2% fan-out gate is never applied at local-authority grain, which needs more than 2%.

    • Factors: at constituency grain the committed values reproduce the body exactly: 1.0096, 1.0192, 1.0082 and 0.9958. At local-authority grain they are 1.0118, 1.0211 (employment counts), 1.0160 and 1.0023. Torbay and Scilly have no SPI LA cell.
    • Why nothing refuses 1.0211:
      • hmrc.employment_income.count at constituency and LA forms an exact-signature group with no control-grain row, so constituency parents LA under the single-winner rule.
      • The sort key at cross_grain.py:256-266, (precedence[lower], -precedence[winning], id), runs constituency_over_la before the bridge's country_over_la.
      • So by the time the bridge pair runs, LA already equals the reconciled constituency total. Its factor is about 1, and _assert_uk_fanout_sum_factors (uk_runtime/ledger_targets.py:2579) only reads bridge groups.
    • The declaration says "the constituency and local-authority cells reconcile to it per grain… a factor beyond 2% is refused". test_uk_fanout_sum_bridges.py only builds a constituency receipt.
    • Fix: either check the bridge against the pre-reconciliation LA total, or gate the signature group too.
    • A related point: the gate's rationale is "the tables agree within 0.5% at source", yet employment counts land at 1.92%. The likely cause is that the area cells take all-ranges growth while the bands take band-specific growth. If so, the gate mostly measures that index choice, and with 0.08 points of headroom the next ITL re-pin may trip it.
  3. The 24 national band bridges move cells by up to 5.7% with no bound.

    • No bound: _apply_band_bridges (national_reconciliation.py:~229) takes factor = control / raw_total and refuses only a non-positive total.
    • Wrong reason: all 24 declarations give the same reason, "about 0.5 percent short".
    • Factors:
      • income tax £12,570–15,000: 1.0573
      • taxpayers £12,570–15,000: 1.0274
      • total income £12,570–15,000: 1.0228
      • income tax £15,000–20,000: 1.0142
      • the rest: 1.002–1.008
    • Knock-on: the £12,570 income-tax bridge includes the West Midlands cell deferred in target_fit_reviewed_exclusions.json:5. That deferral was measured against a 55.1m target, which becomes about 58.3m. The body's "Reconciliation factors" section leaves the band bridges out.
    • Fix: add a max_factor_shift as the fan-out bridges have, state the real gaps per bridge, and re-measure the deferral (UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145 holds the same deferral as stale).
  4. Nothing enforces the signatures the body asks for.

    • Unsigned: all 15 holds and the small-cell rule are approved_by: null, status: "proposed".
    • What the loader checks: load_uk_uprating_holds (uprating_holds.py:62-84) checks only kind and reason. expires_on is optional (:169); every entry has one today, so that part is latent.
    • Contrast: the measure exclusions refuse an entry without approved_by, approved_on or expires_on (measure_simulation.py:719).
    • Small cells: the 16 cells are labelled signed_deferred in the membership while the rule is unsigned.
    • Fix: make proposed or a null approver fail closed, so the open decision can't be merged around.

Should fix

  1. "Value moves" doesn't match the committed registers, and no receipt is committed.

    • Active national rows go from 1,231 to 1,267: 36 added, not 37, and none removed.
    • Moved: 160 rows move at register level, against the body's 168.
    • Missing from the count: the moves this PR's own reconciliation makes (the band bridges on the regional SPI rows and the CGT regional rows). So the "before" receipt seems to postdate 7a7ddbdf0.
    • Can't be re-run: tools/uk_target_surface_receipt.py needs local .codex-work inputs.
    • Please commit the before receipt taken at aca40a692 and the after receipt, or at least their diff, with the band-bridge and both fan-out grains' legs.
  2. The doctrine has escape paths.

    • Relations: declared relations (31) only silence the static overlap check (cross_grain_declarations.py:373-395). Nothing tests "subset" (parts ≤ whole) numerically, so an infeasible pair would compile.
    • Coverage path: the coverage check runs only on the joint path (uk_runtime/ledger_targets.py:2464), not in the national-only loader.
    • is_uprating_hold: it returns False for any row carrying uprating_factor (uprating_holds.py:110). tenure_drift.py:119 stamps factor 1 on every non-English tenure cell, so the Welsh, Scottish and NI tenure cells (a few hundred) are held at census shares without a declaration.
    • Missing periods: a missing or unparseable period also returns "not held" instead of refusing.
  3. The 919 higher-rate area cells.

    • One factor per region: each region's factor (1.19 London to 1.40 NI LAs) keeps 2023-24 shares within the region. Across regions the growth is steeper where the base share is lower: about 1.37 for NI at roughly a 5% share, against 1.19 for London at about 12%. Within-region spreads are wider than that, so a uniform factor within a region probably overstates affluent areas and understates poor ones.
    • Rounding: every one of the 919 values is a multiple of 1,000, 192 are at or below 5,000, and the minimum is 2,000. That's the rounding the PR cites for leaving additional-rate cells unbound. NI constituencies sum to 89k against 87k for NI LAs.
    • Adjudications not amended: local_binding_adjudications.json full_frs_tei_band_unavailable still says hmrc_income_by_area binds only the employment and self-employment amount and count cells. population_universe_private_households still says mid-2024.

Minor and questions

  1. Land: the production-2023 parity receipt gains ledger_absent for the three ons.land.* rows, which resolved at 2023 on the base.
  2. rolled_forward_by_ratio:
    • The base sums every eligible fact at the latest period with no count or duplicate check (ledger_targets.py:~2359).
    • The operands aren't required to be the same series apart from the period; only the (0.5, 2] bound guards them.
    • The current NI and Wales values are right.
  3. SPREE drift: as far as I can tell it only moves social renting against everything else; the private-rent-to-owned ratio barely moves in any authority. Social-stock factors such as Rushmoor's (about 0.76) look like reclassifications, and nothing bounds them. That matters for PR B, which builds on tenure/private_rent.
  4. Question: Scottish tax_band. The engine applies Scottish thresholds to income including savings and dividends, while HMRC's Scottish rates apply to non-savings, non-dividend income. This affects only Scotland's national higher and additional rows. It's tested against fakes only, as the body says.
  5. Question: Scotland and Wales. The feed at 942a1fa appears to carry NRS 2025 households by council area and Welsh Government mid-2024 households by LA. The census-share hold keeps 2022 and 2021 shares instead. Is that deliberate, given rules 1 and 2 of the vintage audit's source preference?
  6. Stale text.
    • docs/uk-local-cross-grain-reconciliation-802.md:115 and :165-168 (A15 and the composition partition).
    • uk_local_target_census.json:848 and :871 (the "all cells bind" counts, which now include deferred cells).
    • d10b8b96b's rationale: those UC rows were already calendar averages at the base.
    • "eleven" DfI values: 17 changed.
    • The Make zero-valued calibration targets intentionally nonzero-weighted #104 changelog says zero targets "keep a positive loss weight", but the code only reports it.

Interaction with #1145 and #1167

Checked and fine:

  • The nations sum to 29,082,020, and A15 doesn't double-scale.
  • The nation household factors match the body.
  • Deferred small cells count in their leg's control and don't inflate neighbours.
  • Spine inputs move only by publisher revisions: the revised mid-2024 population and the DfI rounding. ons_household_expenditure_facts is metadata only.
  • The US and loader digest moves come from solve.py being an attested kernel, with no US value change.
  • The source-indexed national compile selects the same candidates by construction.

juaristi22 added a commit that referenced this pull request Oct 11, 2026
…s hold (#1123)

Engine CI on #1198: the US pool tool's run config pins the US spec digest,
which #104's solve.py edit moved (as us-f0-coverage.json already records),
and the diesel per-head donor factor moves 0.036% with ONS's revised
mid-2024 population in the 942a1fa feed, past its 5e-4 pin.

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

Nothing enforced the signatures #1198 asks for: all 15 holds and the
small-cell rule were approved_by null and proposed, and the compile honoured
them anyway. A hold is now in force only once it carries approved_by and
approved_on and is no longer proposed; the holds check refuses the rows of
an unsigned entry as it refuses undeclared ones, and the loader requires
tracking and expires_on on every entry. A small-cell rule nobody signed
defers nothing (its cells stay bound), and the runtime refuses a membership
that defers under an unsigned rule.

test_every_committed_hold_is_signed fails until the register is signed, so
the open decision cannot be merged around.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 11, 2026
Must-fix 2. The 2% fan-out gate read the reconciliation receipt, where the
single-winner rule had already put the local-authority cells on the
constituency total, so that grain's leg always read one. The gate now reads
the cells before any reconciliation, at every grain, and tests source
agreement: the bands' total over the cells' total before either side's
uprating index. The applied factor and the index gap are receipted beside
it. On the pinned feed the source factors are 0.999 to 1.007 at
constituency grain and 1.006 to 1.014 at local-authority grain; the applied
employment-count factors of 1.0192 and 1.0211 are 1.44% index choice
(band-by-band growth on the bands, all-ranges growth on the cells).

Must-fix 3. The 24 national band bridges had no bound and one shared reason
("about 0.5 percent short"). Each now declares max_factor_shift (its
measured gap plus half a point) and its own gap: the shortfall is the
taxpayers Table 3.11's UK row carries in no region, concentrated in the
lowest band (2.7% of taxpayers, 5.7% of income tax at 12,570 to 15,000).

Should-fix 6. A declared subset relation names its part and whole and is
tested on the compiled values in both loaders (a local part at each grain
on its own). The national-only loader runs the coverage check. An applier
that moved nothing stamps uprating_hold and stays a hold: the Welsh,
Scottish and Northern Irish tenure cells are now declared. A row with no
readable fact period is refused instead of passing as not held.

Minor 9. A roll-forward base resolves exactly one cell, or one for each
geography it names, and its numerator and denominator must be one series at
two periods.

Minor 10. A SPREE tenure share that moves by more than 10% in two years is
a stock reclassification (Rushmoor's social stock falls 22%): that
authority's census shares are held. The docstring says what the drift
carries: social renting against everything else.

The receipt tool writes the fan-out legs per grain, the sum bridges and the
subset relations, and names its inputs.

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

Must-fix 1, the part this repository can do: the UC area extract carries no
Payment Indicator, so its cells count every household on UC (6.73m across
Great Britain) while the national rows count paid claims (6.20m). The
selector notes said the bridge "reconciles like with like"; they, the bridge
and the partition now state the gap, the single GB factor (0.921) and the
child bands' resulting spread against the national paid rows. The fix is a
paid-claims area series from Chronicle and a re-pin.

Should-fix 7: a proposed rule (unsigned, so not in force) would defer the
192 higher rate area cells of 5,000 or fewer, where HMRC's rounding to the
thousand is 10% or more of the cell.

Stale text: the #104 fragment (the census reports a zero target's weight, it
does not enforce one), the DfI count (seventeen values, not eleven), the
A15 and household-bridge paragraphs of the cross-grain doc, and the census
notes that said every council-tax cell binds.

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

An unsigned rule defers nothing, so the 16 council-tax band cells below the
floor are bound again: 21,804 active local references, 342 signed
deferrals. Rushmoor's four tenure cells hold their census shares. National
references, the release manifest and the uk-data parity register regenerate
unchanged. The council-tax and surface pins now follow the rule's signature
state, so signing it moves them with the regenerated references rather than
breaking them.

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

A receipt of a draft surface may need a local overlay (a hold register
signed for the run, since an unsigned hold refuses the compile). It now
records code_modified_paths beside code_head rather than passing as the
commit's own.

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

The tax_band bindings were tested against fakes only. An engine test now
pins the classification the national Table 2.2 rows and the higher rate
area cells rely on, for rest-of-UK and Scottish taxpayers, and that the
area metric counts higher rate taxpayers only.

It also pins the gap the review asked about: a Scottish taxpayer with
40,000 of earnings and 5,000 of dividends is HIGHER on the engine, which
compares total income with the Scottish thresholds, where HMRC applies
Scottish rates to non-savings, non-dividend income and counts no higher
rate taxpayer. The same person in England is BASIC. Only Scotland's two
national rows carry it.

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

The receipts behind the PR's value moves were not in the tree, and the
"before" one postdated this branch's own national reconciliation, so the
band-bridge and CGT regional moves were missing from the count. The before
receipt is now the merge base aca40a6 with only the receipt tool, on the
feed it pins; the after receipt is 5ad4816.

Bound national rows go from 1,167 to 1,203: 36 added, none removed, 552
moved (360 SPI regional and 24 CGT regional cells by reconciliation; 168 by
the re-pin and the household controls). Local cells go from 20,885 to
21,804: 919 added, none removed, 18,374 moved.

docs/evidence/uk-target-surface-1123 holds the row-level national diff, the
local diff by target and grain, and the after surface's reconciliation (the
band bridges with their bounds, each fan-out bridge at both grains, every
group's factors, the subset relations and the holds), with the script that
extracts them. The after receipt was run over a register signed for the
run only, which it records.

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

Copy link
Copy Markdown
Collaborator Author

Reply to the adversarial review (Claude Code), at 40a69a911

I checked each finding against the code and the pinned feed, and they hold. Fixes are in 68a0025d8..40a69a911; one must-fix needs a Chronicle extract before it can close. Engine CI failed on two stale pins (the US spec digest and a diesel donor factor), fixed in 68a0025d8; the engine-uk lane passes locally.

Must fix

  1. UC by area: confirmed, and only partly fixable here. On the feed the area cells average 6.73m across Great Britain and the national paid caseload is 6.20m, so the 0.921 is the paid share, not a households-against-benefit-units gap. Your child-band figures reproduce exactly (+2.8%, −1.5%, −3.5%, −5.1%).
    • Done here: the selector notes, the bridge and the partition no longer say "like with like". They state the two concepts, the single GB factor and the child-band spread.
    • Still open: the paid-claims area series needs a Chronicle re-extract with Payment Indicator and a re-pin. A request to Chronicle is drafted and not yet filed. The item I added to WS-C deferred adjudications and the reconciliation layer #736 misdiagnosed the gap; it is corrected.
  2. Fan-out gate at local-authority grain: fixed. Your factors reproduce (1.0118, 1.0211, 1.0160, 1.0023), and so does the cause: the single-winner rule put the local-authority cells on the constituency total before the bridge leg ran.
    • The gate now reads the cells before any reconciliation, at each grain.
    • It tests source agreement: the bands over the cells before either side's uprating index. Your related point was right: 1.44% of the employment-count factor is index choice (band-by-band growth against all-ranges growth).
    • Measured source factors: 0.999 to 1.007 at constituency grain and 1.006 to 1.014 at local-authority grain, against the 2% bound. The applied factor and the index gap are receipted beside them.
  3. Band bridges: bounded, with their real reasons.
    • The gap is not "about 0.5% short". It is the taxpayers Table 3.11's UK row carries in no region: 2,850k in the lowest band against 2,774k across the twelve areas. They are concentrated in the lowest band, hence 2.7% of taxpayers and 5.7% of income tax there, and under 1% above £20,000.
    • Each bridge now declares max_factor_shift (its measured gap plus half a point) and states its own gap. A wider factor is refused.
    • Not done: re-measuring the West Midlands deferral against its moved target (55.1m to about 58.3m) needs a calibration run. It is owed at the next national build.
  4. Signatures: enforced.
    • Holds: an entry without approved_by and approved_on, or still proposed, is not in force. The compile refuses its rows, and the loader requires tracking and expires_on.
    • Small-cell rule: an unsigned rule defers nothing, so the 16 cells are bound again, and the runtime refuses a membership that defers under an unsigned rule.
    • test_every_committed_hold_is_signed fails until the register is signed. That is deliberate: engine-free CI stays red on that one test until the decision is taken.

Should fix

  1. Value moves and receipts: corrected and committed. You were right on each count.
    • The "before" receipt was wrong. It postdated this branch's own national reconciliation, so those moves were missing. It is now the merge base aca40a692 with only the receipt tool, on the feed it pins.
    • Bound national rows: 1,167 to 1,203. 36 added, none removed, 552 moved: 360 SPI regional and 24 CGT regional cells by reconciliation, and 168 by the re-pin and the household controls.
    • Local cells: 20,885 to 21,804. 919 added, none removed, 18,374 moved.
    • Committed: docs/evidence/uk-target-surface-1123/ holds the row-level national diff, the local diff by target and grain, and the reconciliation (band bridges with bounds, both fan-out grains, every group's factors, the subset relations, the holds), plus the extraction script. The full receipts are 6.5 and 7.4 MB and stay outside the tree.
    • One caveat: unsigned holds refuse the compile, so the after receipt ran over a register signed for the run only. The receipt records the file under code_modified_paths.
    • Re-running still needs the Chronicle consumer artifact and the ladder; the tool's docstring and the evidence README now say where they come from.
  2. Escape paths: closed.
    • Subset relations name their part and whole and are tested on the compiled values in both loaders. All 17 hold on the feed; the six CGT cross-table pairs carry a 1% rounding allowance.
    • The national-only loader runs the coverage check.
    • uprating_factor of one: an applier that moved nothing now stamps uprating_hold and stays a hold. The 264 held tenure cells (Wales, Scotland, Northern Ireland and Rushmoor) are declared under a new, unsigned entry.
    • A row with no readable period is refused. None exists on the feed at either grain.
  3. Higher rate area cells.
    • Rounding: confirmed. 192 of the 919 cells are at or below 5,000 (155 constituencies, 37 local authorities), and the median constituency cell is 8,000. I added a proposed rule that would defer those 192. It is unsigned, so it is not in force: sign it or strike it.
    • One factor per region: not changed. It is the stated limit of a control_rescaled hold until the 2024-25 area tables arrive.
    • Adjudications: both entries are yours, signed 2026-09-02, so I have not edited them. Proposed wording is below for your approval.

Minor and questions

  1. Land at 2023. The three rows vanish at period 2023 because the selector now pins publication_status: preliminary, which only the end-2025 rows carry. Without the pin the 2025 compile sees two series and refuses. I left it: the 2023 surface is the legacy production fixture. A proper fix is an edition preference in the resolver; say if you want it here.
  2. Roll-forward: fixed. The base must resolve exactly one cell, or one for each geography it names, and the two operands must be one series at two periods. Both hold for Wales and Northern Ireland on the feed.
  3. SPREE: confirmed and bounded. The private-rent-to-owned ratio moves by under 0.1% in every authority, so the drift carries information on social stock only; the docstring now says so. A share that moves by more than 10% in two years holds that authority's census shares. Rushmoor (social stock −22%) is the only one.
  4. Scottish tax_band: you are right, and it is an engine question. The engine classifies a Scottish taxpayer on total income against the Scottish thresholds; HMRC applies Scottish rates to non-savings, non-dividend income only. A new engine test pins the case: £40,000 of earnings plus £5,000 of dividends is HIGHER in Scotland on the installed engine and BASIC in England, where HMRC would count no higher rate taxpayer. The same test pins the other classifications the targets rely on, so the measure is no longer tested against fakes only. I have not measured the size of the over-count. Your call: keep Scotland's two national cells bound and file the engine issue, or defer them until the engine is fixed.
  5. Scotland and Wales by authority: not deliberate. The feed carries NRS 2025 households for all 32 councils (summing to the Scotland control exactly) and Welsh Government mid-2024 households for all 22 authorities, plus LPS stock by district. By rules 1 and 2 those should set the authority cells, with census shares kept only where no estimate exists. I have not built it: it changes the household design (three nation-scoped authority targets, their bridges and the tenure partition's parents). Say if you want it in this PR.
  6. Stale text: fixed, apart from the commit message of d10b8b96b, which I can only correct by rewriting pushed history. You are right that those UC rows were calendar averages at the base, so the hold I had declared for them was never needed.

Verification at 40a69a911

  • microcosm-build UK and shared: 7,133 of 7,135. One failure is the signature test, by design; the other is local (pe-uk installed in the engine-free venv).
  • engine-uk: 164 of 164, the eight new tax_band tests among them.
  • microcosm-calibrate 361 of 361; microcosm-graph 755 of 757 (two Orrery parser tests need a node package not installed here).
  • Every new check passes on the pinned feed.

#1145 and #1167

Noted. Whichever merges second carries 942a1fa, and a paid-claims UC re-pin would move the pin again, so the three should be sequenced before any of them leaves draft.

Proposed adjudication wording (for your signature)

  • full_frs_tei_band_unavailable: "…the hmrc_income_by_area family binds the unbanded per-area employment and self-employment amount and count references and the higher rate taxpayer count (England, Wales and Northern Ireland)…"
  • population_universe_private_households: "…bind the ONS total-usual-resident universe as published: mid-2025 for local authorities, mid-2024 for constituencies until their mid-2025 edition, the same universe the national mid-2025 controls bind…"

@juaristi22

Copy link
Copy Markdown
Collaborator Author

Re-review (Claude Code) at 40a69a911

Static review of 67fc1dd04..40a69a911 against the previous review and the reply. I checked the code, docs/evidence/uk-target-surface-1123/ and the registers, and ran nothing. The CI run for this head was still queued.

Verdict.

  • Fixed: items 3, 4, 6, 9, 10, 11 and 13.
  • Reproduced exactly: the committed receipts. National goes 1,167 → 1,203 (+36, 0 removed, 552 moved = 360 SPI regional + 24 CGT regional + 168). Local goes 20,885 → 21,804 (+919, 18,374 moved).
  • Needs another pass: must-fix 2 was closed by changing what the gate measures, so the factor the cells actually take is now unbounded. The deliberately red signature test also stops the whole engine-free job.

Previous items

# Status Note
1 UC by area Text fixed; substance open No "like with like" remains. The paid-claims extract is pending in Chronicle.
2 Fan-out gate Partly fixed; see A It now reads the pre-reconciliation cells at each grain (uk_runtime/ledger_targets.py:2621-2694). The source factors reproduce: 0.9992–1.0066 constituency, 1.0058–1.0144 LA. Only the source factor is gated.
3 Band bridges Fixed Bounds are required and enforced (national_reconciliation.py:82, :247), and all 24 factors sit inside them. Reasons match the receipt to 0.1 pt. The West Midlands re-measure is still owed.
4 Signatures Fixed The loader requires tracking and expires_on. An unsigned hold or small-cell rule is out of force, and the 16 cells are bound again. The head can't compile a UK surface until the holds are signed, which is intended. See C and D.
5 Receipts Fixed The numbers above verify, and the extraction script is correct. See G for provenance.
6 Escape paths Fixed; see F Subset relations are checked in both loaders, and the national loader runs coverage. The tenure applier now stamps uprating_hold (264 = 65 × 4 + Rushmoor × 4), and a period that can't be read is refused.
7 Higher-rate small cells Inert, as stated 192 cells (155 + 37); the rule is unsigned so it defers nothing.
8 Land Open, acknowledged
9 Roll-forward Fixed One base cell per geography; operands one series at two periods.
10 SPREE Fixed Rushmoor (0.763) is the only share past 10% in two years; the next widest is 6.9%.
11 Scottish tax_band Pinned test_uk_tax_band_measure.py pins the Scotland-vs-England case. The decision is still yours.
12 Scotland and Wales households Open (question to María)
13 Stale text Fixed, with new spots See H.

68a0025d8 is legitimate:

  • The diesel factor: it follows this PR's own re-pin of UK mid-2024 population (69,281,437 → 69,256,274). The tolerance is unchanged; only the centre moved.
  • The US digest: it comes from this PR's solve.py edit, which us-f0-coverage.json already records.

New

A. The fan-out gate no longer bounds what the cells take.

  • _assert_uk_fanout_sum_factors refuses only source_factor. applied_factor and the index gap are receipt-only.
  • So LA employment counts take ×1.0211 with nothing checking it, and the 1.44-point index gap passes.
  • The test that refused an applied 1.03 is gone. test_uk_fanout_sum_bridges.py:151 now asserts that a 5.4% applied factor passes.
  • Gating source agreement is the right addition, but it replaces the bound instead of joining it. A future ITL re-pin that widens the band-vs-all-ranges gap would move the area targets silently.
  • Fix: also bound the index (or applied) factor per grain, measured gap + 0.5 pt as the band bridges do.

B. LA cells take per-region factors up to 1.043 that nothing gates.

  • Factors (reconciliation.json, constituency_over_la groups):
    • employment count, South West: 1.0430 (every other region 1.018–1.021);
    • employment amount, South West: 1.0294;
    • self-employment amount, North East: 1.0294.
  • Cause: the single-winner rule scales LA cells to the constituency total region by region. Torbay and the Isles of Scilly have no SPI LA cell, so their share of the South West lands on the other South West authorities.
  • Consequence: South West LA targets ask about 2.4% more than the constituency targets for the same households.
  • The body's "0.996 to 1.021" and the gate's applied_factor are UK-level, so this doesn't show. It predates the fix commits.
  • Fix: drop the absent authorities' constituencies from that leg's parent, or gate per-leg factors.

C. The deliberately failing test stops the engine-free job.

  • tools/run_engine_free_tests.sh runs --maxfail=1 -n 2. When test_every_committed_hold_is_signed fails, pytest stops scheduling.
  • So "red on that one test" means the rest of the engine-free suite goes unverified in CI on every push until the holds are signed.
  • Fix: mark it xfail(strict=True) with the reason, so CI goes red only once it unexpectedly passes. Or move the check into a merge gate.

Minor

  • D. "Signed" means "not proposed". uk_hold_is_signed (uprating_holds.py:162-172) and uk_small_cell_rule_is_signed (ledger_targets.py:882) accept rejected, withdrawn or draft once an approver and date are set. Use an allowlist (status == "approved").
  • E. The source factor falls back silently to the uprated value when ledger_value_before_alignment is missing (_spec_value_before_alignment, ledger_targets.py:2617; also national_reconciliation.py:407). That would mix periods on one side of the gate. It should refuse instead.
  • F. The subset checks are thin.
    • They compare aggregate totals only.
    • age_0_9_by_region ≤ uk_total is trivially true (ratio 0.106), while the stated constraint (all bands ≤ total less 90+) isn't tested.
    • max_excess is unbounded at runtime; only the test caps it.
    • A relation with an unbound side is skipped silently (national_reconciliation.py:460).
  • G. Receipt provenance.
    • code_modified_paths records paths only, with no hash or diff, and ignores untracked files (tools/uk_target_surface_receipt.py:75). So "the signed-for-the-run overlay moved no value" can't be checked from the receipt.
    • The "before" receipt names code_head: aca40a692, but compile_uk_full_target_surface doesn't exist there. The README says so in words; the receipt doesn't.
  • H. Stale text. docs/uk-local-cross-grain-reconciliation-802.md:98 ("a factor more than 2% from one is refused") and changelog.d/1123-hmrc-area-taxpayer-basis.changed.md ("within 2%") still describe the applied-factor gate.
  • I. The band-bridge bounds are drift tripwires (measured + 0.5 pt by construction), not plausibility bounds. If the feed carries Table 3.11's UK row, bounding each bridge by UK row ÷ Σ regions would check the stated explanation in code.
  • Nits.
    • Eight taxpayer band-bridge reasons read "the taxpayers of taxpayers Table 3.11's UK row carries".
    • The higher-rate rule says "rounding error of at least 10%"; it means up to 10% (25% at 2,000).
    • assert_uk_uprating_holds_declared(holds=...) skips the loader's validation, and a missing expires_on now raises a KeyError.
    • A deferral under a rule id later deleted from the register still passes assert_uk_local_deferrals_in_force.

Still open, as the reply says:

  • the paid-claims UC extract;
  • the West Midlands re-measure against its moved target;
  • land at 2023;
  • the Scotland and Wales household estimates;
  • the Scottish tax_band decision;
  • signing the holds, the tenure entry and the two small-cell rules.

No tolerance was loosened. The count edits (21,788 → 21,804, 1,598 → 1,582) follow from the 16 cells being bound again. The only test weakened is the fan-out applied-factor refusal (A).

@juaristi22
juaristi22 force-pushed the uk-target-surface-1123 branch from 40a69a9 to b8509c7 Compare October 12, 2026 11:26
juaristi22 added a commit that referenced this pull request Oct 12, 2026
…s hold (#1123)

Engine CI on #1198: the US pool tool's run config pins the US spec digest,
which #104's solve.py edit moved (as us-f0-coverage.json already records),
and the diesel per-head donor factor moves 0.036% with ONS's revised
mid-2024 population in the 942a1fa feed, past its 5e-4 pin.

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

Nothing enforced the signatures #1198 asks for: all 15 holds and the
small-cell rule were approved_by null and proposed, and the compile honoured
them anyway. A hold is now in force only once it carries approved_by and
approved_on and is no longer proposed; the holds check refuses the rows of
an unsigned entry as it refuses undeclared ones, and the loader requires
tracking and expires_on on every entry. A small-cell rule nobody signed
defers nothing (its cells stay bound), and the runtime refuses a membership
that defers under an unsigned rule.

test_every_committed_hold_is_signed fails until the register is signed, so
the open decision cannot be merged around.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 12, 2026
Must-fix 2. The 2% fan-out gate read the reconciliation receipt, where the
single-winner rule had already put the local-authority cells on the
constituency total, so that grain's leg always read one. The gate now reads
the cells before any reconciliation, at every grain, and tests source
agreement: the bands' total over the cells' total before either side's
uprating index. The applied factor and the index gap are receipted beside
it. On the pinned feed the source factors are 0.999 to 1.007 at
constituency grain and 1.006 to 1.014 at local-authority grain; the applied
employment-count factors of 1.0192 and 1.0211 are 1.44% index choice
(band-by-band growth on the bands, all-ranges growth on the cells).

Must-fix 3. The 24 national band bridges had no bound and one shared reason
("about 0.5 percent short"). Each now declares max_factor_shift (its
measured gap plus half a point) and its own gap: the shortfall is the
taxpayers Table 3.11's UK row carries in no region, concentrated in the
lowest band (2.7% of taxpayers, 5.7% of income tax at 12,570 to 15,000).

Should-fix 6. A declared subset relation names its part and whole and is
tested on the compiled values in both loaders (a local part at each grain
on its own). The national-only loader runs the coverage check. An applier
that moved nothing stamps uprating_hold and stays a hold: the Welsh,
Scottish and Northern Irish tenure cells are now declared. A row with no
readable fact period is refused instead of passing as not held.

Minor 9. A roll-forward base resolves exactly one cell, or one for each
geography it names, and its numerator and denominator must be one series at
two periods.

Minor 10. A SPREE tenure share that moves by more than 10% in two years is
a stock reclassification (Rushmoor's social stock falls 22%): that
authority's census shares are held. The docstring says what the drift
carries: social renting against everything else.

The receipt tool writes the fan-out legs per grain, the sum bridges and the
subset relations, and names its inputs.

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

Must-fix 1, the part this repository can do: the UC area extract carries no
Payment Indicator, so its cells count every household on UC (6.73m across
Great Britain) while the national rows count paid claims (6.20m). The
selector notes said the bridge "reconciles like with like"; they, the bridge
and the partition now state the gap, the single GB factor (0.921) and the
child bands' resulting spread against the national paid rows. The fix is a
paid-claims area series from Chronicle and a re-pin.

Should-fix 7: a proposed rule (unsigned, so not in force) would defer the
192 higher rate area cells of 5,000 or fewer, where HMRC's rounding to the
thousand is 10% or more of the cell.

Stale text: the #104 fragment (the census reports a zero target's weight, it
does not enforce one), the DfI count (seventeen values, not eleven), the
A15 and household-bridge paragraphs of the cross-grain doc, and the census
notes that said every council-tax cell binds.

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

An unsigned rule defers nothing, so the 16 council-tax band cells below the
floor are bound again: 21,804 active local references, 342 signed
deferrals. Rushmoor's four tenure cells hold their census shares. National
references, the release manifest and the uk-data parity register regenerate
unchanged. The council-tax and surface pins now follow the rule's signature
state, so signing it moves them with the regenerated references rather than
breaking them.

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

A receipt of a draft surface may need a local overlay (a hold register
signed for the run, since an unsigned hold refuses the compile). It now
records code_modified_paths beside code_head rather than passing as the
commit's own.

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

The tax_band bindings were tested against fakes only. An engine test now
pins the classification the national Table 2.2 rows and the higher rate
area cells rely on, for rest-of-UK and Scottish taxpayers, and that the
area metric counts higher rate taxpayers only.

It also pins the gap the review asked about: a Scottish taxpayer with
40,000 of earnings and 5,000 of dividends is HIGHER on the engine, which
compares total income with the Scottish thresholds, where HMRC applies
Scottish rates to non-savings, non-dividend income and counts no higher
rate taxpayer. The same person in England is BASIC. Only Scotland's two
national rows carry it.

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

The receipts behind the PR's value moves were not in the tree, and the
"before" one postdated this branch's own national reconciliation, so the
band-bridge and CGT regional moves were missing from the count. The before
receipt is now the merge base aca40a6 with only the receipt tool, on the
feed it pins; the after receipt is 5ad4816.

Bound national rows go from 1,167 to 1,203: 36 added, none removed, 552
moved (360 SPI regional and 24 CGT regional cells by reconciliation; 168 by
the re-pin and the household controls). Local cells go from 20,885 to
21,804: 919 added, none removed, 18,374 moved.

docs/evidence/uk-target-surface-1123 holds the row-level national diff, the
local diff by target and grain, and the after surface's reconciliation (the
band bridges with their bounds, each fan-out bridge at both grains, every
group's factors, the subset relations and the holds), with the script that
extracts them. The after receipt was run over a register signed for the
run only, which it records.

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

uk_hold_is_signed and uk_small_cell_rule_is_signed accepted any status but
proposed, so a rejected or withdrawn entry with an approver and a date was in
force. Both now share one allowlist: an approver, an approval date and the
status approved.

Also from the re-review's nits:
- assert_uk_uprating_holds_declared validates a register passed in as the
  loader validates the committed one, so a missing expires_on is a named
  refusal and not a KeyError.
- A rule deferral whose rule is no longer in uk/local_small_cell_rule.json is
  refused. Only a rule deferral carries expires_on, which is how one is told
  from the other signed deferrals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 12, 2026
… their neighbours (#1198 re-review, A, B, E, H)

A. The fan-out gate bounded only the source factor, so the factor the cells
take was unbounded: a re-pinned ITL index could move the area targets
unseen. Each fan-out sum bridge now also declares max_index_shift (its
measured index gap plus half a point: 0.010, 0.020, 0.007, 0.009) and the
gate refuses an index factor past it, at each grain. Both parts of the
applied factor are bounded.

B. South West local authorities took up to 1.043 against 1.018 to 1.021
elsewhere, because HMRC's local-authority tables carry no Torbay or Isles of
Scilly row and the leg's bound cells took their share of the constituency
total. The same happened wherever a signed cell is off the surface: the City
of London (deferred with its value: 1.8% of London's self-employment
income) and one constituency with no self-employment mean.
uk_runtime/off_surface_cells.py puts those cells in the reconciliation
frame, where a control moves their leg. A cell with a value joins with it,
moved as its leg was moved. A cell with none is implied from the target's
other local tier over the legs where both tiers are whole. An absent cell
no tier can imply is refused. The cells never reach the solve. The fan-out
gate counts them, so a grain that lacks a few authorities no longer reads
as a source disagreement.

On the pinned feed: 44 frame rows (38 with a value, 6 implied). Torbay and
the Isles of Scilly are implied at 2.3% of the South West's employees
(their household share is 2.6%). Local-authority leg factors against the
constituencies: employment count 1.018 to 1.021 (was up to 1.043),
employment amount 1.009 to 1.011 (was up to 1.029).

E. The source factor fell back to the uprated value when
ledger_value_before_alignment was missing. uk_value_before_uprating now
refuses a spec that carries an uprating_factor and no source value, on both
sides of the gate.

H. The cross-grain doc and the changelog fragments describe the two gates,
the signature rule and the off-surface cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 added a commit that referenced this pull request Oct 12, 2026
The subset checks compared one part's total with its whole, so they were
thin in four ways:

- The age relation tested the 0-9 band alone against the UK total (ratio
  0.106), where its reason is about all the bands. A relation can now name
  other_parts, disjoint targets tested on their sum: the nine age bands and
  the two 90-and-over rows together equal the UK total on the pinned feed.
- Only totals were compared. Where a part and its whole both carry cells at
  one geography, the part is now tested there too.
- max_excess was capped only by a test. The check refuses an allowance
  above UK_SUBSET_MAX_EXCESS (2%).
- A relation with an unbound side was skipped silently. It is now refused,
  because it would stand untested. Two skips remain, each with its reason
  in the receipt: a narrowed register that binds neither side, and a local
  part in the national-only loader, which has no local surface.

On the pinned feed the joint surface tests all 20 and skips none; the
national-only loader tests 14 and skips the 3 local parts.

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

A band bridge's max_factor_shift is its measured gap plus half a point: a
tripwire for drift, not a test of the reason the declaration gives. The
reason is that Table 3.11's United Kingdom row counts taxpayers HMRC places
in no region, so the twelve regional areas sum short of it.

The feed carries that UK row, so the compile now tests the reason. Each of
the 24 bridges declares explained_by: hmrc_spi_table_3_11_uk_row, and its
factor must sit within half a point of the UK row over the regional cells'
sum at the table's own year. The UK rows are vendored
(uk/hmrc_spi_table_3_11_uk_rows.json, 120 rows from the pinned feed).

On the pinned feed 21 of the 24 factors equal the explained factor to five
decimals. The other three span two SPI bands under one ITL band and differ
by the two publications' rounding: 0.19, 0.05 and 0.04 points.

Also drops a doubled word from eight bridge reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22 and others added 25 commits October 12, 2026 15:35
The joint local surface refused its own fan-out sum control: the synthetic
control's signature carried filters at top level, and the reconciliation
canonicalizes only a 'measurement' mapping. The test now runs the synthetic
signature through that canonicalization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s hold (#1123)

Engine CI on #1198: the US pool tool's run config pins the US spec digest,
which #104's solve.py edit moved (as us-f0-coverage.json already records),
and the diesel per-head donor factor moves 0.036% with ONS's revised
mid-2024 population in the 942a1fa feed, past its 5e-4 pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…must-fix 4)

Nothing enforced the signatures #1198 asks for: all 15 holds and the
small-cell rule were approved_by null and proposed, and the compile honoured
them anyway. A hold is now in force only once it carries approved_by and
approved_on and is no longer proposed; the holds check refuses the rows of
an unsigned entry as it refuses undeclared ones, and the loader requires
tracking and expires_on on every entry. A small-cell rule nobody signed
defers nothing (its cells stay bound), and the runtime refuses a membership
that defers under an unsigned rule.

test_every_committed_hold_is_signed fails until the register is signed, so
the open decision cannot be merged around.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Must-fix 2. The 2% fan-out gate read the reconciliation receipt, where the
single-winner rule had already put the local-authority cells on the
constituency total, so that grain's leg always read one. The gate now reads
the cells before any reconciliation, at every grain, and tests source
agreement: the bands' total over the cells' total before either side's
uprating index. The applied factor and the index gap are receipted beside
it. On the pinned feed the source factors are 0.999 to 1.007 at
constituency grain and 1.006 to 1.014 at local-authority grain; the applied
employment-count factors of 1.0192 and 1.0211 are 1.44% index choice
(band-by-band growth on the bands, all-ranges growth on the cells).

Must-fix 3. The 24 national band bridges had no bound and one shared reason
("about 0.5 percent short"). Each now declares max_factor_shift (its
measured gap plus half a point) and its own gap: the shortfall is the
taxpayers Table 3.11's UK row carries in no region, concentrated in the
lowest band (2.7% of taxpayers, 5.7% of income tax at 12,570 to 15,000).

Should-fix 6. A declared subset relation names its part and whole and is
tested on the compiled values in both loaders (a local part at each grain
on its own). The national-only loader runs the coverage check. An applier
that moved nothing stamps uprating_hold and stays a hold: the Welsh,
Scottish and Northern Irish tenure cells are now declared. A row with no
readable fact period is refused instead of passing as not held.

Minor 9. A roll-forward base resolves exactly one cell, or one for each
geography it names, and its numerator and denominator must be one series at
two periods.

Minor 10. A SPREE tenure share that moves by more than 10% in two years is
a stock reclassification (Rushmoor's social stock falls 22%): that
authority's census shares are held. The docstring says what the drift
carries: social renting against everything else.

The receipt tool writes the fan-out legs per grain, the sum bridges and the
subset relations, and names its inputs.

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

Must-fix 1, the part this repository can do: the UC area extract carries no
Payment Indicator, so its cells count every household on UC (6.73m across
Great Britain) while the national rows count paid claims (6.20m). The
selector notes said the bridge "reconciles like with like"; they, the bridge
and the partition now state the gap, the single GB factor (0.921) and the
child bands' resulting spread against the national paid rows. The fix is a
paid-claims area series from Chronicle and a re-pin.

Should-fix 7: a proposed rule (unsigned, so not in force) would defer the
192 higher rate area cells of 5,000 or fewer, where HMRC's rounding to the
thousand is 10% or more of the cell.

Stale text: the #104 fragment (the census reports a zero target's weight, it
does not enforce one), the DfI count (seventeen values, not eleven), the
A15 and household-bridge paragraphs of the cross-grain doc, and the census
notes that said every council-tax cell binds.

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

An unsigned rule defers nothing, so the 16 council-tax band cells below the
floor are bound again: 21,804 active local references, 342 signed
deferrals. Rushmoor's four tenure cells hold their census shares. National
references, the release manifest and the uk-data parity register regenerate
unchanged. The council-tax and surface pins now follow the rule's signature
state, so signing it moves them with the regenerated references rather than
breaking them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its commit (#1198 review)

A receipt of a draft surface may need a local overlay (a hold register
signed for the run, since an unsigned hold refuses the compile). It now
records code_modified_paths beside code_head rather than passing as the
commit's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w, question 11)

The tax_band bindings were tested against fakes only. An engine test now
pins the classification the national Table 2.2 rows and the higher rate
area cells rely on, for rest-of-UK and Scottish taxpayers, and that the
area metric counts higher rate taxpayers only.

It also pins the gap the review asked about: a Scottish taxpayer with
40,000 of earnings and 5,000 of dividends is HIGHER on the engine, which
compares total income with the Scottish thresholds, where HMRC applies
Scottish rates to non-savings, non-dividend income and counts no higher
rate taxpayer. The same person in England is BASIC. Only Scotland's two
national rows carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…review, should-fix 5)

The receipts behind the PR's value moves were not in the tree, and the
"before" one postdated this branch's own national reconciliation, so the
band-bridge and CGT regional moves were missing from the count. The before
receipt is now the merge base aca40a6 with only the receipt tool, on the
feed it pins; the after receipt is 5ad4816.

Bound national rows go from 1,167 to 1,203: 36 added, none removed, 552
moved (360 SPI regional and 24 CGT regional cells by reconciliation; 168 by
the re-pin and the household controls). Local cells go from 20,885 to
21,804: 919 added, none removed, 18,374 moved.

docs/evidence/uk-target-surface-1123 holds the row-level national diff, the
local diff by target and grain, and the after surface's reconciliation (the
band bridges with their bounds, each fan-out bridge at both grains, every
group's factors, the subset relations and the holds), with the script that
extracts them. The after receipt was run over a register signed for the
run only, which it records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
María signed every entry of uk/uprating_holds.json on 2026-10-11. Each
expires on 2026-12-11, two months after the signature, in place of the
proposed 2027 dates, so every hold is reviewed again by then. Status moves
from proposed to approved.

The compile no longer refuses the committed register, and
test_every_committed_hold_is_signed passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w, D)

uk_hold_is_signed and uk_small_cell_rule_is_signed accepted any status but
proposed, so a rejected or withdrawn entry with an approver and a date was in
force. Both now share one allowlist: an approver, an approval date and the
status approved.

Also from the re-review's nits:
- assert_uk_uprating_holds_declared validates a register passed in as the
  loader validates the committed one, so a missing expires_on is a named
  refusal and not a KeyError.
- A rule deferral whose rule is no longer in uk/local_small_cell_rule.json is
  refused. Only a rule deferral carries expires_on, which is how one is told
  from the other signed deferrals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their neighbours (#1198 re-review, A, B, E, H)

A. The fan-out gate bounded only the source factor, so the factor the cells
take was unbounded: a re-pinned ITL index could move the area targets
unseen. Each fan-out sum bridge now also declares max_index_shift (its
measured index gap plus half a point: 0.010, 0.020, 0.007, 0.009) and the
gate refuses an index factor past it, at each grain. Both parts of the
applied factor are bounded.

B. South West local authorities took up to 1.043 against 1.018 to 1.021
elsewhere, because HMRC's local-authority tables carry no Torbay or Isles of
Scilly row and the leg's bound cells took their share of the constituency
total. The same happened wherever a signed cell is off the surface: the City
of London (deferred with its value: 1.8% of London's self-employment
income) and one constituency with no self-employment mean.
uk_runtime/off_surface_cells.py puts those cells in the reconciliation
frame, where a control moves their leg. A cell with a value joins with it,
moved as its leg was moved. A cell with none is implied from the target's
other local tier over the legs where both tiers are whole. An absent cell
no tier can imply is refused. The cells never reach the solve. The fan-out
gate counts them, so a grain that lacks a few authorities no longer reads
as a source disagreement.

On the pinned feed: 44 frame rows (38 with a value, 6 implied). Torbay and
the Isles of Scilly are implied at 2.3% of the South West's employees
(their household share is 2.6%). Local-authority leg factors against the
constituencies: employment count 1.018 to 1.021 (was up to 1.043),
employment amount 1.009 to 1.011 (was up to 1.029).

E. The source factor fell back to the uprated value when
ledger_value_before_alignment was missing. uk_value_before_uprating now
refuses a spec that carries an uprating_factor and no source value, on both
sides of the gate.

H. The cross-grain doc and the changelog fragments describe the two gates,
the signature rule and the off-surface cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subset checks compared one part's total with its whole, so they were
thin in four ways:

- The age relation tested the 0-9 band alone against the UK total (ratio
  0.106), where its reason is about all the bands. A relation can now name
  other_parts, disjoint targets tested on their sum: the nine age bands and
  the two 90-and-over rows together equal the UK total on the pinned feed.
- Only totals were compared. Where a part and its whole both carry cells at
  one geography, the part is now tested there too.
- max_excess was capped only by a test. The check refuses an allowance
  above UK_SUBSET_MAX_EXCESS (2%).
- A relation with an unbound side was skipped silently. It is now refused,
  because it would stand untested. Two skips remain, each with its reason
  in the receipt: a narrowed register that binds neither side, and a local
  part in the national-only loader, which has no local surface.

On the pinned feed the joint surface tests all 20 and skips none; the
national-only loader tests 14 and skips the 3 local parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew, I)

A band bridge's max_factor_shift is its measured gap plus half a point: a
tripwire for drift, not a test of the reason the declaration gives. The
reason is that Table 3.11's United Kingdom row counts taxpayers HMRC places
in no region, so the twelve regional areas sum short of it.

The feed carries that UK row, so the compile now tests the reason. Each of
the 24 bridges declares explained_by: hmrc_spi_table_3_11_uk_row, and its
factor must sit within half a point of the UK row over the regional cells'
sum at the table's own year. The UK rows are vendored
(uk/hmrc_spi_table_3_11_uk_rows.json, 120 rows from the pinned feed).

On the pinned feed 21 of the 24 factors equal the explained factor to five
decimals. The other three span two SPI bands under one ITL band and differ
by the two publications' rounding: 0.19, 0.05 and 0.04 points.

Also drops a doubled word from eight bridge reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iew, G)

A receipt named its commit and the tracked paths that differed from it, with
no digest and no untracked files. So a receipt run over an overlay could not
be checked, and one run with the tool laid over an older commit named that
commit as if it carried the tool.

The receipt now records every file under packages and tools that differs
from code_head, tracked or not, with the SHA-256 of its content, and the
diff of the tracked ones (its digest always, its text up to 256 kB). It also
carries the off-surface cells' receipt. The evidence extracts keep the file
list and the diff's digest for both receipts.

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

A cell rounded to the thousand can be up to 500 from its true count: up to
10% of a cell of 5,000 and up to 25% of the smallest cell. The rule's reason
said "at least 10%".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full-graph engine tests run on a small synthetic ladder, and the
committed membership signs areas that ladder does not hold (the Isles of
Scilly), so the off-surface cells refused the compile there.

The rule is the leg licences' own: under the committed crosswalk an area
that cannot be placed is a register defect and refuses; under a run's own
ladder it is an area the run does not carry, so it is left out and named in
the receipt (areas_outside_the_ladder).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The after receipt is now bc32a49c1, on a clean tree: the hold register is
signed, so the compile needs no overlay. The before receipt is re-run with
the provenance record and names the six files laid over aca40a6; its rows
are identical to the earlier one.

National rows are unchanged from the last extract: 1,167 to 1,203, 36
added, none removed, 552 moved. Local cells go from 20,885 to 21,804: 919
added, none removed, 18,811 moved. 437 more cells move than at 40a69a9:
the council-tax band cells of London, the South West and Scotland's band H,
by up to 0.5%, because a deferred authority no longer leaves its share on
its neighbours.

reconciliation.json adds the off-surface cells (44 frame rows), each band
bridge's explained factor, and both gated parts of each fan-out factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch now sits on d1fda72. main brought the 1ee7dfe feed re-pin, the
ONS 07.2.2 household road-fuel target and the measure exclusions of VAT, the
two ONS energy rows and the OBR cars receipts (microcosm#1113), the US
state AGI bands (#940) and the PUF-support change (#1178). Where the two
sides touched one hunk the rebase kept the branch's side; this commit puts
both sides in and regenerates what depends on them, on the 942a1fa feed
(which holds every 1ee7dfe row).

- National references: 1,268 active, the branch's 1,267 plus the road-fuel
  target. The contract has 400 targets (348 registry, 52 profile; 156
  unmapped declarations).
- The two national parity receipts and the reference membership are
  regenerated; the local references, the local census, the local parity
  receipt and uk_data_target_parity come out byte-identical.
- desnz_road_fuel_by_vehicle.json is vendored again under the 942a1fa feed
  identity; the UK spine parity fixture follows it.
- Pins that both sides moved are recomputed on the merged code: the seed
  protocol and seed map digests (#1178's puf_support and #104's solve
  edit), the loader golden vector, the US spec digest and the US coverage
  evidence.
- The re-pin doc keeps both re-pins in order; 942a1fa is on top of 1ee7dfe.

The compiled surface on the pinned feed: 1,200 bound national rows (four
rows are now measured diagnostics, one is new) and 21,804 local cells, every
value equal to the branch's before the rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch is rebased on main at d1fda72, so both receipts are run again.
Before is that commit with only the receipt tool, on the 1ee7dfe feed main
pins. After is 67e7af0 on the 942a1fa feed, on a clean tree.

Bound national rows go from 1,164 to 1,200: 36 added, none removed, 552
moved. main now measures VAT, the two ONS energy rows and the OBR cars
receipts as diagnostics and binds ONS 07.2.2 road fuel (microcosm#1113), so
both counts are three lower than before the rebase; the rows this branch
adds and moves are the same. Local cells go from 20,885 to 21,804: 919
added, none removed, 18,811 moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch now sits on 51a2637. main brought the landlord work of
microcosm#1145: the 13 SPI Table 3.7 property-income amounts bound unscaled
and uprated by the band index, the property components stage, the PRIS
resource, and policyengine-uk 2.123.0; and #1188 and #1211. Where the two
sides touched one hunk the rebase kept the branch's side; this commit puts
both sides in and regenerates what depends on them, on the 942a1fa feed.

- The SPI property-income amount target takes main's note and band index
  and keeps this branch's taxpayer filter (income_tax > 0), as every other
  SPI row does here (decision "taxpayer basis everywhere").
- National references: 1,281 active, the branch's 1,268 plus the 13 property
  amounts.
- The vendor selections keep both sides' resources;
  hmrc_property_rental_income_facts.json is vendored again under the 942a1fa
  feed identity, and the UK spine parity fixture follows it.
- The two national parity receipts and the reference membership are
  regenerated; the local references, the local census and the local parity
  receipt come out byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…indow (#1198 re-review 2, item 1)

All 16 holds expire on 2026-12-11, and the evaluation date defaults to
today, so from the next day every national and joint UK build refuses 117
target ids. Nothing warned: the feed-backed compiles are skipped in CI, so
CI would stay green while the builds failed.

- The compile warns when a held target's declaration is within
  UK_HOLD_EXPIRY_NOTICE_DAYS (14) of its expiry, and the receipt carries the
  earliest expiry.
- test_no_committed_hold_is_about_to_lapse fails in the same window, while
  the builds still pass. It is a clock on purpose: from 2026-11-27 it is red
  until the holds are reviewed and signed again, or replaced by a newer
  fact or an index.
- A hold is in force from approved_on through expires_on, as a measure
  exclusion is: evaluated before its signature it is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… items 2 and 3, nits)

2. An implied row is what a leg's bound cells do not hold, so an undercount
anywhere in the leg passed into it and through every gate that counts it.
Its share of the leg is now bounded by its areas' share of the leg's census
households: at most UK_IMPLIED_SHARE_MAX_RATIO (1.5) times that share, or
the compile refuses. On the pinned feed the ratios are 0.32 (higher rate
taxpayers) to 0.89 (employees) for Torbay and the Isles of Scilly, and 0.37
for the one North West constituency. An implied area with no census
households to set it against is refused; a surface that binds no census
households says in the receipt that the row is unbounded.

3. An off-surface area outside a run's own ladder was left out with a
receipt line whatever the run was. It is now left out only when the
membership binds some cell for the area: a whole run then binds that cell,
and the frame refuses a bound cell its ladder cannot place, so a run that
gets that far is a narrowed one (a sample or a fixture). An area the
membership binds nothing for has no such guard and refuses. A test pins that
every committed off-surface area is one a whole run binds.

Nits:
- full_problem's own surface passes the membership's deferred and
  off-surface cells, as the graph's does.
- A deferred cell with a value counts in its leg's total in every leg, so a
  leg no control moves reads whole when the two tiers' ratio is taken; it
  adds a frame row where a control moves either tier of its leg.
- The evidence extraction writes the before receipt's overlay diff beside
  the extracts, so it can be read in the tree.

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

#1145 merged with these items in its description only; its author's reply
said the receipts note would take them in a follow-up. They are: the HBAI
size of the FRS add-back at 2025 with the engine follow-up
(PolicyEngine/policyengine-uk#2390), furnished holiday lettings from
2025-26, and the effect of capital netting on the finance-cost draw. The
figures are the description's. No code changes; the control arm on the
current base is still owed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch is rebased again, on main after #1145, so both receipts are run
again. Before is 51a2637 with only the receipt tool, on the 1ee7dfe feed.
After is cd39d03 on the 942a1fa feed, on a clean tree.

Bound national rows go from 1,177 to 1,213: 36 added, none removed, 552
moved. main's 13 SPI property-income amounts are on both sides at the same
values, so both counts are 13 higher than before the rebase; the rows this
branch adds and moves are the same. Local cells go from 20,885 to 21,804:
919 added, none removed, 18,811 moved.

The before receipt's overlay is now in the tree as before-overlay.diff, and
each implied cell carries its household share and share ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 force-pushed the uk-target-surface-1123 branch from b8509c7 to a4df360 Compare October 12, 2026 15:32
@juaristi22

Copy link
Copy Markdown
Collaborator Author

Reply to the re-review at b8509c765 (Claude Code), now at a4df36092

All three minor items and the nits are addressed, with two exceptions that I state below. The fixes are in a864c842a..a4df36092.

The branch is rebased again, onto main at 51a263726, because #1145, #1188 and #1211 merged. Every commit id changed again: the head you reviewed, b8509c765, is now a864c842a.

CI on b8509c765 finished green on every lane, engine-us on both Python versions included. That closes the "unverified pins" nit for that head: the US spec digest, the seed digests and the coverage evidence passed where they are checked.

Minor items

1. The expiry cliff: fixed, with a clock in CI.

  • The compile warns when a held target's declaration is within 14 days of its expiry (UK_HOLD_EXPIRY_NOTICE_DAYS), and the holds receipt carries the earliest expiry.
  • A test fails in the same window: test_no_committed_hold_is_about_to_lapse. From 2026-11-27 the engine-free lane is red on every PR until the holds are reviewed and signed again, or replaced by a newer fact or an index. The builds still pass for those 14 days.
  • That is a deliberate clock. María, say if you prefer a dated tracking issue and no test; it is a one-test removal.
  • You are right that most of these holds will need a new signature, not retirement: the constituency mid-2025 ages and the SPI 2024-25 area tables land after 11 December.

2. Implied cells: bounded. Per-leg tier factors: not bounded, on purpose.

  • An implied row's share of its leg is now at most 1.5 times its areas' share of the leg's census households (UK_IMPLIED_SHARE_MAX_RATIO). Past that the compile refuses: the leg's bound cells are short by more than the absent areas can explain.
  • On the feed the ratios are 0.32 to 0.89:
    • Torbay and the Isles of Scilly hold 2.61% of the South West's households. They are implied at 2.33% of its employees (0.89), 1.97% of employment income (0.75), 1.75% and 1.71% of self-employment counts and income (0.67 and 0.66) and 0.85% of higher rate taxpayers (0.32).
    • The North West constituency holds 1.31% of the region's households and is implied at 0.48% of its self-employment income (0.37).
  • Two refusals beside the bound: an implied area with no census households to set it against is refused, and a surface that binds no census households says in the receipt that the row is unbounded.
  • I did not bound the leg factors per target.
    • The local-authority census-household legs at 0.973 to 1.038 are the nation and region controls themselves (decision "nations win"): the constituency cells take them, and the authority cells follow. A bound there would be a bound on the LFS and NRS estimates.
    • The North East self-employment amount at 1.029 is the two tables' gap at source, and the standing rule lets the constituency tier win.
    • Both stay in the receipt by leg.

3. The ladder rule: tightened, with a different guard from the one you proposed.

  • An off-surface area outside a run's ladder is left out only when the membership binds some cell for that area. If the membership binds nothing for the area, the compile refuses.
  • Why that closes the hole. A whole run binds that cell, and the frame refuses a bound cell that its ladder cannot place. So a production ladder that lacks Torbay refuses on Torbay's bound age and household cells before any off-surface cell is read. A run that reaches the skip is a narrowed one, which is what the fixture is.
  • A test pins the premise: every committed off-surface area is one a whole run binds.
  • I did not add a dev or sample mode flag. The guard above is derived from the data, so a production caller cannot switch it off.

Nits

  • full_problem.py: fixed. Its own surface passes the membership's deferred and off-surface cells.
  • Deferred cells in non-controlled legs: fixed. A deferred cell with a value counts in its leg's total in every leg, and adds a frame row where a control moves either tier of its leg.
  • G, the before overlay: the extraction now writes the before receipt's overlay as before-overlay.diff in the evidence folder, with its digest in surface-diff.json.
  • Signature dates: fixed. A hold is in force from approved_on through expires_on, as a measure exclusion is.
  • The obr.fuel_duties_cars hold: not edited. The entry is signed, so its text is María's. Proposed sentence for her next review of the register: "Since microcosm#1113 this row is a measured diagnostic and not a calibration target; the hold stays because holds are asserted on the full register."
  • Bisect: unchanged, and one span longer. This rebase adds a second reconciliation commit (21da310f7). The branch's commits between main and that commit carry branch-side conflict picks. A squash at merge removes the problem; that choice is María's.
  • F, local parts on their grain total: noted, not changed.

Rebase onto main at 51a263726

  • Nine files conflicted. 21da310f7 records the reconciliation.
  • One real merge decision: the SPI property-income amounts. UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145 binds the 13 Table 3.7 amounts unscaled, on the band index. This branch puts every SPI row on a taxpayer basis. The merged target has both: main's note and index, and this branch's income_tax > 0 filter. So those 13 rows are now measured over taxpayers only, which differs from what UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145 fitted. The filter is the SPI's own universe. The 13 target values are the same on main and here. I have not measured how much the filter changes their fit; that shows at the next national build.
  • National references: 1,281 active, the branch's 1,268 plus the 13 property amounts.
  • The West Midlands deferral is retired on main, so nothing is deferred against the cell this branch moves. Its fit is measured with every other row at the next national build.
  • No branch value moved in the rebase or in this round. The compiled surface has 1,213 bound national rows, the 1,200 of the last head plus the 13 property amounts, and the same 21,804 local cells. Every value equals its value at b8509c765.

Left over from #1145

Vahid's review of 63943a76b had three items that #1145 recorded in its description only. They are now in experiments/1106-uk-property-income-receipts.md (section C11, cd39d037e): the HBAI size of the FRS add-back at 2025 with PolicyEngine/policyengine-uk#2390, furnished holiday lettings from 2025-26, and capital netting in the finance-cost draw. The figures are the description's. The control arm on the current base is still owed.

Value moves

  • Against the new main: 1,177 to 1,213 bound national rows. 36 added, none removed, 552 moved, the same rows as before. 20,885 to 21,804 local cells: 919 added, none removed, 18,811 moved.
  • This round moves no value. The share bound, the ladder guard and the holds notice are checks.

Verification at cd39d037e

cd39d037e is the last code commit; a4df36092 adds only the evidence files.

  • engine-uk: 180 of 180.
  • The engine-free job's files: 16,693 tests; 141 skip and 6 fail. The six are the same as in the last round and none is from this branch: two Orrery parser tests (a node package), one CGT test (pe-uk installed in the engine-free environment), one telemetry test (unstable under parallel workers), one head-to-head scorer test (needs Python 3.14.7), and one microcosm-data test that reads a live Hugging Face pointer.
  • Receipts: the after receipt ran clean at cd39d037e on a clean tree; the before receipt is main at 51a263726. On the feed: 44 off-surface frame rows, an empty areas_outside_the_ladder, all 20 subset tests, and an earliest hold expiry of 2026-12-11 at both grains.
  • Lint, the CI test plan, the burndown check and the vendored-resource check: ok.
  • Not run here: the engine-us lane (no US engine on this machine). The US spec digest is unchanged by this rebase, and the coverage tool passes its 41 inventory checks on the merged code.
  • Not run: the dense re-solve.

Still open

@juaristi22

Copy link
Copy Markdown
Collaborator Author

The taxpayer filter on the 13 SPI property-income rows: measurement and decision (Claude Code)

Decision (María, 2026-10-12): keep the filter. The 13 SPI Table 3.7 property-income amounts that #1145 binds are measured over taxpayers only on this branch (income_tax > 0), as every other SPI row is. main measures them over every person in the band. The 13 target values are the same on both sides.

Why the two measures differ

What was measured

Total income band All people, £bn Taxpayers, £bn Gap, £bn Gap Landlords, thousands Of them, pay no tax Of them, with finance-cost relief Records that pay no tax
£12,570 to £15,000 0.74 0.67 0.065 8.8% 106.1 7.9 0.2 103
£15,000 to £20,000 1.73 1.58 0.158 9.1% 228.5 13.2 7.0 30
£20,000 to £30,000 4.02 3.63 0.384 9.6% 434.2 21.6 19.5 42
£30,000 to £40,000 4.47 4.37 0.093 2.1% 411.7 3.6 3.2 16
£40,000 to £50,000 4.44 4.40 0.040 0.9% 367.0 1.0 1.0 2
£50,000 to £70,000 6.90 6.90 0.000 0.0% 486.6 0.0 0.0 0
£70,000 to £100,000 4.81 4.81 0.000 0.0% 277.0 0.0 0.0 0
£100,000 to £150,000 3.30 3.30 0.000 0.0% 171.5 0.0 0.0 0
£150,000 to £200,000 1.40 1.40 0.000 0.0% 63.4 0.0 0.0 0
£200,000 to £300,000 1.34 1.34 0.000 0.0% 49.4 0.0 0.0 0
£300,000 to £500,000 0.98 0.98 0.000 0.0% 27.7 0.0 0.0 0
£500,000 to £1,000,000 0.61 0.61 0.000 0.0% 13.0 0.0 0.0 0
£1,000,000 and over 0.55 0.55 0.000 0.0% 23.3 0.0 0.0 0
All 13 bands 35.29 34.55 0.740 2.1% 2,659.4 47.3 30.8 193
  • Below £12,570 (outside every band, on both measures): £0.58bn on 138 thousand landlords.
  • The engine run is at 2025 values, so the banded total is 5.0% above the sum of the 13 targets (£33.60bn), which the calibration measures on its own path. The shares are the result; the £ gap is about £0.7bn on the targets' basis.

What it means

  • People who pay no income tax hold 2.1% of the property income in the bands (£0.74bn of £35.29bn).
  • The gap is in the low bands: 9.4% in the three bands below £30,000, 1% to 2% from £30,000 to £50,000, and nil above £50,000.
  • 47,281 of 2.66m landlords in the bands pay no income tax. 30,823 of them have a finance-cost reduction, which takes their liability to nil. In the lowest band most have another cause, which I did not decompose.
  • At the next calibration the three low bands start about 9% short on this branch's measure, so the solver raises the weight of taxpaying landlords there. UK landlords: bind the SPI net property amounts, leave property_wealth to the engine, add receipts and finance costs #1145's fit of 0.3% on these rows was measured on main's measure and does not carry over.
  • Total property income ends about 2% higher than on main's measure, because the landlords who pay no tax are then outside the target and not inside it. The PRIS diagnostics move up with it. Neither is measured: no calibration was run.
  • The rows now depend on the modelled finance costs, which decide who pays no tax. The gap rests on 193 records.
The script
"""Property income by SPI total-income band on the #1145 c9 candidate: all people against taxpayers."""
import json, os, sys, warnings
os.environ.setdefault("HDF5_USE_FILE_LOCKING", "FALSE")
warnings.simplefilter("ignore")
import numpy as np
import importlib.metadata as metadata
from policyengine_uk import Microsimulation
YEAR = 2025
dataset = sys.argv[3]  # the calibrated c9 candidate, microcosm_uk_2024_25.h5
sim = Microsimulation(dataset=dataset)
def col(name):
    v = sim.calculate(name, YEAR)
    return np.asarray(v.values, dtype=float), np.asarray(v.weights, dtype=float)
prop, w = col("property_income")
total, _ = col("total_income")
tax, _ = col("income_tax")
relief, _ = col("property_finance_cost_relief")
costs, _ = col("property_finance_costs")
edges = [12570, 15000, 20000, 30000, 40000, 50000, 70000, 100000, 150000, 200000, 300000, 500000, 1000000, np.inf]
receipt = json.load(open(sys.argv[1]))  # a target-surface receipt, for the 13 target values
targets = {r["name"]: r["value"] for r in receipt["national"] if r["name"].startswith("hmrc/property_income_income_band_")}
landlord = prop > 0
payer = tax > 0
rows = []
for lo, hi in zip(edges[:-1], edges[1:]):
    band = (total >= lo) & (total < hi)
    all_amt = float((prop * w)[band].sum()); pay_amt = float((prop * w)[band & payer].sum())
    all_n = float(w[band & landlord].sum()); pay_n = float(w[band & landlord & payer].sum())
    nonpay = band & landlord & ~payer
    rows.append({"band": lo, "all_gbp": all_amt, "taxpayers_gbp": pay_amt, "all_landlords": all_n, "taxpayer_landlords": pay_n,
                 "nonpayer_with_relief": float(w[nonpay & (relief > 0)].sum()), "nonpayer_records": int(nonpay.sum())})
out = {"engine": metadata.version("policyengine-uk"), "year": YEAR, "dataset": dataset, "bands": rows,
       "below_12570": {"all_gbp": float((prop*w)[total < 12570].sum()), "landlords": float(w[(total < 12570) & landlord].sum())},
       "all_property_income_gbp": float((prop*w).sum()), "landlords": float(w[landlord].sum()),
       "targets": targets}
json.dump(out, open(sys.argv[2], "w"), indent=1)
ta = sum(r["all_gbp"] for r in rows); tp = sum(r["taxpayers_gbp"] for r in rows)
print("engine", out["engine"], "| all property income £bn", round(out["all_property_income_gbp"]/1e9,2), "landlords m", round(out["landlords"]/1e6,3))
print("in the 13 bands: all people £bn", round(ta/1e9,2), "| taxpayers £bn", round(tp/1e9,2), "| non-taxpayers £bn", round((ta-tp)/1e9,2), f"({(ta-tp)/ta:.1%})", "| targets £bn", round(sum(targets.values())/1e9,2), len(targets))
print("landlords in bands: all m", round(sum(r['all_landlords'] for r in rows)/1e6,3), "taxpayers m", round(sum(r['taxpayer_landlords'] for r in rows)/1e6,3), "non-taxpayers with relief m", round(sum(r['nonpayer_with_relief'] for r in rows)/1e6,3), "records", sum(r['nonpayer_records'] for r in rows))
for r in rows:
    print(f"  {r['band']:>8}: all £{r['all_gbp']/1e9:6.2f}bn taxpayers £{r['taxpayers_gbp']/1e9:6.2f}bn gap {1-r['taxpayers_gbp']/r['all_gbp'] if r['all_gbp'] else 0:6.1%} | landlords {r['all_landlords']/1e3:7.1f}k taxpayers {r['taxpayer_landlords']/1e3:7.1f}k | non-payers with relief {r['nonpayer_with_relief']/1e3:6.1f}k ({r['nonpayer_records']} records)")
print("below 12,570: £bn", round(out["below_12570"]["all_gbp"]/1e9,2), "landlords m", round(out["below_12570"]["landlords"]/1e6,3))
The output
{
 "engine": "2.123.0",
 "year": 2025,
 "bands": [
  {
   "band": 12570,
   "all_gbp": 739209114.2499585,
   "taxpayers_gbp": 673939177.727847,
   "all_landlords": 106072.8392278701,
   "taxpayer_landlords": 98167.14690719545,
   "nonpayer_with_relief": 164.04813385009766,
   "nonpayer_records": 103
  },
  {
   "band": 15000,
   "all_gbp": 1734569916.506525,
   "taxpayers_gbp": 1576771007.521718,
   "all_landlords": 228460.7329572402,
   "taxpayer_landlords": 215265.92346588895,
   "nonpayer_with_relief": 7034.7943078279495,
   "nonpayer_records": 30
  },
  {
   "band": 20000,
   "all_gbp": 4016857379.8852468,
   "taxpayers_gbp": 3632881784.709773,
   "all_landlords": 434235.018026039,
   "taxpayer_landlords": 412633.3857693523,
   "nonpayer_with_relief": 19504.546014398336,
   "nonpayer_records": 42
  },
  {
   "band": 30000,
   "all_gbp": 4467844392.178325,
   "taxpayers_gbp": 4374841463.2569685,
   "all_landlords": 411677.84457619116,
   "taxpayer_landlords": 408056.6843499057,
   "nonpayer_with_relief": 3161.4855183959007,
   "nonpayer_records": 16
  },
  {
   "band": 40000,
   "all_gbp": 4440908596.628444,
   "taxpayers_gbp": 4401200624.90477,
   "all_landlords": 367039.04541874677,
   "taxpayer_landlords": 366080.90053547174,
   "nonpayer_with_relief": 958.144883275032,
   "nonpayer_records": 2
  },
  {
   "band": 50000,
   "all_gbp": 6896780804.749105,
   "taxpayers_gbp": 6896780804.749105,
   "all_landlords": 486628.99004568905,
   "taxpayer_landlords": 486628.99004568905,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 70000,
   "all_gbp": 4811365242.8906,
   "taxpayers_gbp": 4811365242.8906,
   "all_landlords": 276958.4430429302,
   "taxpayer_landlords": 276958.4430429302,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 100000,
   "all_gbp": 3301603916.086019,
   "taxpayers_gbp": 3301603916.086019,
   "all_landlords": 171501.82781397924,
   "taxpayer_landlords": 171501.82781397924,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 150000,
   "all_gbp": 1401580825.345178,
   "taxpayers_gbp": 1401580825.345178,
   "all_landlords": 63391.11676461995,
   "taxpayer_landlords": 63391.11676461995,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 200000,
   "all_gbp": 1337064761.5412693,
   "taxpayers_gbp": 1337064761.5412693,
   "all_landlords": 49441.01511074789,
   "taxpayer_landlords": 49441.01511074789,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 300000,
   "all_gbp": 976727442.7887601,
   "taxpayers_gbp": 976727442.7887601,
   "all_landlords": 27743.166271453723,
   "taxpayer_landlords": 27743.166271453723,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 500000,
   "all_gbp": 610383423.6390421,
   "taxpayers_gbp": 610383423.6390421,
   "all_landlords": 12970.121472515166,
   "taxpayer_landlords": 12970.121472515166,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  },
  {
   "band": 1000000,
   "all_gbp": 552367083.8276165,
   "taxpayers_gbp": 552367083.8276165,
   "all_landlords": 23303.13035662286,
   "taxpayer_landlords": 23303.13035662286,
   "nonpayer_with_relief": 0.0,
   "nonpayer_records": 0
  }
 ],
 "below_12570": {
  "all_gbp": 575829853.843927,
  "landlords": 137855.0947300978
 },
 "all_property_income_gbp": 35863092754.16002,
 "landlords": 2797278.385814743,
 "targets": {
  "hmrc/property_income_income_band_100_000_to_150_000": 3151071428.571429,
  "hmrc/property_income_income_band_12_570_to_15_000": 703500000.0,
  "hmrc/property_income_income_band_150_000_to_200_000": 1332747524.7524753,
  "hmrc/property_income_income_band_15_000_to_20_000": 1651431535.2697096,
  "hmrc/property_income_income_band_1_000_000_to_inf": 525041487.8397711,
  "hmrc/property_income_income_band_200_000_to_300_000": 1272816349.3840985,
  "hmrc/property_income_income_band_20_000_to_30_000": 3831324110.671937,
  "hmrc/property_income_income_band_300_000_to_500_000": 927337625.9798431,
  "hmrc/property_income_income_band_30_000_to_40_000": 4252163461.538461,
  "hmrc/property_income_income_band_40_000_to_50_000": 4229783653.8461537,
  "hmrc/property_income_income_band_500_000_to_1_000_000": 580715469.6132597,
  "hmrc/property_income_income_band_50_000_to_70_000": 6559199999.999999,
  "hmrc/property_income_income_band_70_000_to_100_000": 4579293333.333333
 },
 "dataset": "arm c9 of PolicyEngine/microcosm#1145 (microcosm_uk_2024_25.h5)"
}

@juaristi22
juaristi22 marked this pull request as ready for review October 12, 2026 16:48
@juaristi22
juaristi22 merged commit 977ac98 into main Oct 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant