Skip to content

UK #1334: compute the local metrics from their declarations, and measure the local HMRC rows over each person's year - #1347

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

juaristi22 merged 5 commits into
mainfrom
uk-annual-income-local

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

On main at 4cae4e426, after #1344 merged. Commits 1 and 2 are rebased from #1344's head with no code change; the national-rows changelog fragment keeps #1344's refreshed costing figures, and this PR's sentence on the local rows. e6ac8b157 labels those figures as from the pre-Budget arm (#1344 review items 25 and 26). Commit 4 answers Vahid's round 3 on this PR. Commit 5 puts the changelog's costing comparison with HMRC on a like-for-like basis (#1370).

Why

The local line computed its 43 metrics in hard-coded code (local_targets.compute_household_metrics), beside the 59 local declarations in uk_population_targets.json. The national line reads its declarations. So a rebind moved the national rows but not the local ones. #1344 moves the national HMRC income rows to each person's year of income: the 12 months before their FRS interview, a proxy for the tax year. The five local hmrc rows could not follow it without a second, hand-written copy.

What changes

Commit 1. The local metrics come from their declarations, with no change in the build's local output.

  • compute_household_metrics(resolver, area_type, ...) reads each metric's policyengine binding through the national rows' interpreter (prepared_binding_values, now public). It reads the columns through UKMeasureResolver. Person and benefit-unit bindings sum onto their household with the engine's map_result, as before.
  • It takes the resolver, not the simulation. The full measure passes each block's resolver. The incumbent extractor builds one over the incumbent file.
  • The incumbent extractor's three high_value_homes/* columns do move on a Microcosm-release incumbent. The old extractor read the stored main residence value and tenure at 2025, where the engine uprates the value, against thresholds deflated to the dataset period. The resolver reads them at the frame's period, as the build's local line already did. So the local rule-1 comparison moves on those three metrics, and the build's local line does not.
  • A local declaration may carry only the binding keys the local line honours (value, expression, filters, from_entity, map_to household). Any other key refuses.
  • Ten of the 59 local declarations change, so that they say what the code computed:
    • The four UC child bands count UC benefit units by uc_calibration_child_count. This is the FRS-role composition that the national UC-by-children rows read. The old code read the engine's stored is_uc_claimant, which the spine writes from the same roles.
    • The two private-rent rows read benunit_rent summed to the household, filtered on the household's tenure.
    • The three high-value homes rows read the resolver's owner_occupied_home_over_gbp_* measures (stored value and tenure, deflated thresholds).
    • The gas-connected households read a new measure, domestic_gas_connected. Like the kWh measures (domestic_electricity_kwh, domestic_gas_kwh, also new in the resolver), it reads the stored spend at the dataset period.
  • The interpreter gains in lists, lower-inclusive ranges and subtraction in value expressions. It refuses a predicate that declares input_period or price_basis, which it cannot honour. No binding carries one now.
  • local_target_references.json is regenerated. Only the recorded entity moves: benunit for the 2,528 UC child-band references, and household for the 334 private-rent references. The membership file does not change.

Commit 2. The local hmrc rows read each person's year of income.

  • The five declarations (self-employment and employment amounts and counts, and higher rate taxpayers; constituency and local authority) read the annual measures for their values, their taxpayer test and their rate band, as UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's national rows do.
  • Target ids, facts, values and metric names do not change.
  • The incumbent extractor reads the incumbent's annual measures as its current values (the R1 fallback, as rule 1 scores the incumbent nationally).

Commit 4. Review round 3.

  • The census fences (item 7). The HMRC area fence's rule now says income_tax_annual > 0, and its authority names the five hmrc declarations. The UC grain fence names the five dwp.uc.households_by_area* declarations, and it and the family's description name the child count the bands read (uc_calibration_child_count, the FRS-role count, not the old code's num_children). The bound_in_code status says the metric comes from its declaration. uk_local_target_census.json is regenerated: those six lines only. María's signed hmrc_spi_frame_model_proxy adjudication is re-worded to the annual frame at both grains, with its dates unchanged.
  • The filter keys (item 6). A local filter may carry only variable, operator, value, equals, in, lower and upper. A filter that names an entity, a concept or a reduction refuses.
  • The eFRS refusal (item 5). The extractor names benunit.uc_calibration_child_count and the incumbent-unresolvable register when an incumbent lacks the UC relationship inputs.
  • The selector notes (items 8 and 9). The five local hmrc notes cite Table 3.15 for constituencies and 3.14 for local authorities. The self-employment count and the two employment rows say where HMRC's concept differs.
  • Items 10 and 11. A comment on the bindings cache, and the energy cache set in UKMeasureResolver.__init__.

Commit 5. The costing comparison with HMRC, like for like (#1370).

Measurement

Parity of commit 1. One resolver over the annual arm's national frame (uk-prebudget-annual-c1e0b099a, calibration year 2025, dataset period 2024). The script computed every local metric of the three grains through the declarations, and through the old function (verbatim from the base: local_targets.py is unchanged on main up to 4cae4e426) on the same engine:

  • constituency: 22 of 22 columns identical, bit for bit;
  • BRMA: 1 of 1 identical;
  • local authority: 37 of 38 identical. ons/equiv_housing_costs differs within float32 precision. The old code subtracted the two equivalised incomes in float32; the new code does it in float64. No active local target reads this metric (the BHC/AHC fence).

The UC child bands are identical, so on this frame the stored is_uc_claimant and the recomputed FRS roles agree. The frame predates #1344's drop of fix 3; parity compares two versions of the code on one frame, so that does not matter. The rebase changes neither the local declarations, the references nor the measures they read (the resolver changes on main since are docstrings, bus-fare pricing and the national DfE spring-term measures).

Commit 2 is not measured on a local build yet. On the local rows it does what #1344 does on the national ones: for example, a recent leaver whose year of income makes them liable to income tax counts in their area.

For review

  • The incumbent extractor on the enhanced FRS. The eFRS has no dependent_children column, so it cannot compute uc_calibration_child_count. The constituency extraction now refuses at the UC child bands, and the error names the measure and the register. The old code computed them from the engine's fallback roles, which is a different composition from the candidate's. The national scorer prunes this measure under the signed incumbent-unresolvable register (benunit.uc_calibration_child_count, in force to 2026-12-22). The local comparison (score_uk_local_candidate.py) needs the same prune before it can run on the eFRS. It is opt-in since UK local-area release: publish a K=15, 150,000-household dense dataset in at most two builds #1301. See "Not in this PR".
  • The energy measures read at the dataset period. On a dataset, policyengine-uk does not uprate the stored spend at 2025 anyway. So this changes nothing today, and it keeps the stage's prices exact if the projection changes.
  • The test fixtures. The engine-free local tests drive the real resolver over a stub engine (test_support/microcosm_build/uk_local_metric_fixtures.py). The fixture's third benefit unit gains a lone parent: the FRS roles need one or two claimants per unit, and the old fixture's lone 12-year-old had none. The expected values do not change. The two engine tests build a small frame and a real resolver.

Tests

On the same code rebased onto #1344's e399d21ef, with policyengine-uk 2.147.2 as the lock pins it. Since then main adds only #1344's changelog commit and #1362, which touches NZ files only.

  • Engine-free: the 165 test files that read the contract, the local references, the local targets, the resolver or the interpreter. 6,330 passed, 47 skipped, 2 failed. The 2 failures are environmental and fail the same way on UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's head here: the orrery reconstruction check needs Node packages that this checkout has not installed, and test_engine_is_reported_unavailable_without_the_uk_extra expects no engine, as CI's engine-free job has none.
  • Engine tier: the 14 files that read the resolver or the interpreter, 37 tests, all pass. Two of them (the local energy and tax-band rows) failed on commit 2 until their small frames carried the annual-income descriptors that production frames carry; commit 2 gives them a full year (share 1, no other spells).
  • ruff check ., tools/ci_test_plan.py verify and tools/graph_acceptance_burndown.py --verify: ok.
  • Commit 4: the four engine-free files it touches or that read the changed registers (local targets, the extractor, the census and the local rowwise solve): 105 passed. ruff check and ruff format --check: ok.
  • The full suite runs in CI, now that this PR targets main.

Not in this PR

  • The prune of incumbent-unresolvable measures in the local comparison (the extractor's manifest and the local scorer), as the national scorer does.
  • A local build on this branch.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my review at 1c42b3de2.

Verdict. The design is right. One interpreter and one resolver now serve the national and local lines, so a rebind moves both. I checked commit 1's parity claim on a second file and it holds. Commit 2 points the five local HMRC rows at the right concept: HMRC's area tables count taxpayers over the tax year. I would not merge yet, for two reasons. First, commit 2 breaks two engine tests that pass on commit 1, and CI won't catch them until the PR retargets (item 1). Second, the branch sits on a superseded #1344 history and on a main without #1333, so it needs a restack (item 2). The rest are shoulds and nits. The main one is that the incumbent extractor's high-value-homes figures do change, which the "no change in output" line does not say (item 3).

What I ran

  • Fresh worktree at 1c42b3de2, uv sync --all-packages --locked --extra uk.
  • The 8 test files the PR touches: 105 passed, 1 skipped, 2 failed (item 1). The same two files pass on commit 1 (005d281ff).
  • The engine-free suites of microcosm-build and microcosm-data: 17,499 passed, 103 skipped, 3 xfailed, 2 failed. The 2 are the environmental ones the PR body names: the orrery check needs Node packages, and the CGT test expects no engine.
  • ruff check . and python3 tools/ci_test_plan.py verify: both ok. CI has no checks on this PR, as it targets uk-annual-income.
  • My own parity check of commit 1, on a second file. I used the cached a858c100 release (microcosm_uk_2024_25.h5), read-only and offline. I ran the old compute_household_metrics (verbatim from 16a2cb836) and the new one at the head, at all three grains. The new one ran the way the new incumbent extractor runs it: through UKMeasureResolver on the h5, with the incumbent fallback for the annual measures. Results are in items 3 and "What looks right". I only compared columns; no area or record values leave my machine.
  • git cherry-pick of the two commits onto the current UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344 head, and git merge-tree against current main (with Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333) and UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's head (item 2).
  • Primary sources: HMRC SPI 2023-24 Table 3.15 (constituency; I read the notes and column headers in the ODS) and the Table 3.14 page (local authority).
  • Not run: no local or national build, and no UKDS or other licensed data.

Generated data versus code. GitHub shows 95 files and +74k lines, but nearly all of that is #1344 underneath. This PR's own two commits are 19 files, +3,943 / −3,447:

  • local_target_references.json is 5,724 of those lines. I checked every changed reference against 16a2cb836. Only the entity field moves: the four UC child bands (632 references each) go from household to benunit, and rent/private_rent (316) and rent/private_rent_brma (18) go from benunit to household. No ids, values or other fields change, and the file carries no model figures.
  • Read in full: the local_targets.py rewrite, the interpreter changes in target_materialization.py, the energy measure in measure_simulation.py, the extractor change, the 10 + 5 declaration changes in uk_population_targets.json, the changelog fragments, and the test diffs.
  • Skimmed: the new fixture module uk_local_metric_fixtures.py (248 lines).

Findings

1. must: commit 2 breaks two engine tests. At the head:

  • tests/engine/uk/test_uk_local_energy_metrics.py::test_la_energy_metrics_read_the_stored_spend_at_the_dataset_period
  • tests/engine/uk/test_uk_tax_band_measure.py::test_the_area_metric_counts_higher_rate_taxpayers_only

Both fail with KeyError: 'person.self_employment_income_after_loss_relief_annual' at local_targets.py:379. Both pass on commit 1. The cause is in the fixtures. Their frames carry no annual income descriptors and set no fallback, so once commit 2 rebinds the HMRC rows, knows("person", "*_annual") is False for every local metric set. The PR body's engine-tier run ("36 tests passed") was on commit 1. On commit 2 only test_uk_annual_income_measures.py was rerun. CI runs only on PRs into main, so this would surface only after the retarget.

Suggested fix: give the two fixtures the descriptor columns, as test_uk_annual_income_measures.py does. Or set the incumbent fallback with a stated reason. Don't add a silent fallback to the production path.

2. must: restack onto the current #1344, and then onto main with #1333.

3. should: the incumbent extractor's high-value-homes figures change. Say so.

  • The old extractor called compute_household_metrics(simulation, grain, period=2025) with no input_period. So it read main_residence_value and tenure_type at 2025, where the engine uprates the stored value, and then compared that with thresholds deflated to the dataset period.
  • The new extractor reads them through the resolver at the frame's period. That is the UK: data fixes to model the High Value Council Tax Surcharge (mansion tax) correctly, and the related housing and council-tax gaps #1240 intent, and it is what the candidate already gets. So this is a fix.
  • On the a858c100 release as incumbent, the new head matches the old code called with input_period=2024 on 60 of 61 columns. The 61st is ons/equiv_housing_costs, at float precision, which the PR already names.
  • Against the old code called as the extractor called it, the three high_value_homes/* columns differ, by about 7%, 8% and 10% of their record sums. The energy columns do not move, as the PR says, because the engine leaves the stored spend alone.
  • So the local rule-1 comparison against a microcosm-release incumbent moves on three metrics.

Please add a sentence to the body and to uk-local-metrics-from-declarations.changed.md. "With no change in output" holds for the build's local line, not for the extractor.

4. should: the local rule-1 comparison has the same like-for-like gap as the national one. The extractor reads the incumbent's annual measures as its current values (use_current_income_for_annual_measures). The candidate is scored on its annual measures. For the five local HMRC rows, the candidate's taxpayer test is income_tax_annual > 0 and the incumbent's is income_tax > 0, both against the same tax-year targets. This is #1344 r1 item 9 again, at area grain. When the local scorer runs, please report its result with these five metrics left out as well.

5. should: the eFRS incumbent path now refuses. Prune it in this PR, or make it refuse clearly.

  • The eFRS has no dependent_children, so frs_uc_claimant_mask raises on the UC child bands. The old code computed them from the engine's fallback roles.
  • I checked the a858c100 release: it carries is_parent, is_benunit_head, is_uc_claimant and dependent_children. So a microcosm-release incumbent works, and my parity run used exactly that path.
  • So the gap only bites on the eFRS, and the local scorer is opt-in. Even so, the refusal an operator gets today is UC relationship benunit inputs missing: ['dependent_children']. That doesn't point to the register.

Either bring the prune into this PR, since the register entry already exists and runs to 2026-12-22, or catch the error in the extractor and name benunit.uc_calibration_child_count and the register in the message.

6. should: the binding-key guard does not look inside filters. _LOCAL_BINDING_KEYS (local_targets.py, near the top of the new block) checks only a binding's top-level keys. A local filter that carries entity, reduce or concept passes the guard and reaches _predicate_mask. There, reduce takes the household-condition route only if the adapter has one; _ResolverColumns does not, so it would be read as a plain predicate. And entity would point at a non-household grain. No local declaration does this today. A small allow-list for filter keys (variable, operator, value, equals, in, lower, upper) would close the gap the guard is there for.

7. should: the census fences still point at the deleted code. Three fences in local_target_census.py cite local_targets.compute_household_metrics (in_spi_frame definition) and "(benefit-unit-to-household mapping)": the HMRC area proxy at about line 869, the UC grain at about line 924, and line 1113.

  • The definitions now live in the declarations.
  • The HMRC fence's rule text also still says income_tax > 0, while commit 2 moves the rows to income_tax_annual > 0. That change is the very thing the fence asks to be adjudicated ("binding any HMRC area family requires an explicit adjudication accepting or replacing that proxy").

Please point the authorities at the five declarations and update the rule text. If the HMRC area rows are bound under a signed adjudication, record there that the proxy is now the annual one.

8. nit: the five selector notes swap HMRC's table numbers. They say "Tables 3.14 (constituency) and 3.15 (local authority)". HMRC's Table 3.14 is "Income and tax by borough and district or unitary authority" and Table 3.15 is "Income and tax by Parliamentary Constituency" (both 2023 to 2024). This is pre-existing text, but these are the declarations this PR edits.

9. nit: two small concept gaps against the HMRC tables, for the notes. Both are pre-existing and both apply to the national rows too.

  • Self-employment count. HMRC's Note 5 counts "all taxpayers who have a self-employment source". The binding counts self_employment_income_after_loss_relief_annual != 0. So a taxpayer whose losses brought forward wipe out the year's profit is in HMRC's count but not the model's. The amount is unaffected.
  • Employment income. Note 5 says HMRC's employment income "includes all pay from employment, from taxable benefits and from Incapacity Benefit, contribution-based Employment and Support Allowance or Job Seeker's allowance". employment_income_annual excludes the benefits.

A sentence in the selector notes would do.

10. nit: _local_metric_bindings is functools.cached on the packaged contract. A test that monkeypatches the contract has to call _local_metric_bindings.cache_clear(), or it reads stale bindings. Worth a comment, or a fixture that clears the cache.

11. nit: UKMeasureResolver._household_energy creates _household_energy_cache through getattr and does not set it in __init__. That is fine, but it is easy to miss when someone adds a reset for the other caches.

Disclosure (small areas)

  • This PR adds no output surface. The only area-level figures in the diff are HMRC's and DWP's own published cells, through the references file, which carries no values. The PR body reports parity as "identical" and "differs", with no area or record figures. My check above follows the same rule.
  • For context, not a finding against this PR: on main after Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333, uk_withheld_row_sets keeps constituency and local-authority rows outside the per-row carrier floor, pending "the local-line ruling still owed". Commit 2 changes who counts in five local rows, for example a leaver who paid tax in the year. No carrier check covers those rows yet. The higher-rate taxpayer count by constituency will be one of the thinnest local rows. So I'd keep "don't publish local diagnostics" in force until that ruling lands. The extractor's per-household metrics CSV is an internal, opt-in scoring input, not a published file.

Merge order and overlaps

  1. UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344 rebases onto main (with Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333), resolving the support-floor and withholding collisions from its r1 review.
  2. This PR then restacks onto UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's new head, with the one comment-block conflict in item 2. After UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344 merges, it retargets to main. Its own files auto-merge with main and with UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338.
  3. UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338 conflicts only with UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's files (gates.json, the manifest, the docs, two tests), not with this PR's. Either order works, and whichever lands second regenerates the pins.
  4. UK pre-Budget data: measure stored values at their period, draw Carer's Allowance claims, and the #1278 items (#1324, #1319, #1278) #1329, Draw UK QRF stages from uniforms keyed on each record's id, and tie pension contributions to pay #1332 and Lock policyengine-uk 2.145.0, follow its bus fares and bind thin target rows #1333 are merged.
  5. Dashboard (uk-childcare-100k-threshold PR Register populace-uk 2023 (private; beats enhanced FRS 6/6) #8): this PR changes only the local line's metrics. The £0.87bn / £0.96bn costing reads national figures. So unless the local solve feeds back into the national weights, the rerun that UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344 already needs covers it. I did not check that feedback.

What looks right

  • Commit 1's parity holds on a second file. On the a858c100 release, with the old function given the dataset period, 22 of 22 constituency columns, 1 of 1 BRMA column and 37 of 38 local-authority columns are identical. The 38th is the float rounding on ons/equiv_housing_costs. The UC child bands are identical too, so the stored is_uc_claimant and the FRS roles agree on the released file as well as on the build frame.
  • Commit 2 matches HMRC's concept. Table 3.15's notes say "only individuals who are liable to tax in tax year 2023 to 2024 are included". They also say self-employment income is profit "less losses brought forward and capital allowances". So the income_tax_annual > 0 test, the after-loss-relief annual profit and tax_band_annual for the higher-rate column are the right reads.
  • The declarations now say what the code computes. The UC child bands read the same uc_calibration_child_count as the national UC-by-children rows. The private rents read benunit_rent summed to the household, filtered on household tenure. The high-value homes and gas connection read resolver measures that apply the stored period. The input_period and price_basis keys left in the contract are all in ledger_selector or measurement blocks, never in a policyengine binding, so the new refusal in _predicate_mask cannot trip on a current declaration.
  • The interpreter changes are small and tested. in lists, ranges with an inclusive lower and exclusive upper bound, and signed expression terms. _expression_terms refuses a leading sign or a malformed name.
  • Duplicate local metric names must bind identically, ignoring notes, across the nation-scoped council-tax families. A non-household map_to refuses.
  • The incumbent extractor reads the incumbent through one resolver, with the fallback reason named. Its test checks the resolver's arguments and the fallback.

🤖 Generated with Claude Code

@juaristi22
juaristi22 force-pushed the uk-annual-income-local branch from 1c42b3d to ab50a90 Compare October 22, 2026 16:01
@juaristi22 juaristi22 changed the title UK #1334: compute the local metrics from their declarations, and measure the local HMRC rows over the tax year UK #1334: compute the local metrics from their declarations, and measure the local HMRC rows over each person's year Oct 22, 2026
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my second look, at ab50a90bb.

Verdict. Both musts from round 1 are resolved. The two engine tests pass, and the branch now sits on #1344 after #1333. Nothing in this PR blocks it once #1344 merges and it retargets to main. Items 3 to 11 are unchanged at the head. Of those, I'd still like two before merge, as both are small and both are about saying what the code now does: item 3 (the extractor's high-value-homes figures do move, while the body says "no change in output" and the changelog is silent on it) and item 7 (the census fences cite deleted code, and their rule text still says income_tax > 0). The rest can follow in a later PR. I also correct one claim of my own from round 1 below.

Status of round 1

# Tag Status Evidence at ab50a90bb
1 must resolved Both named tests pass. The fix is in the two fixtures (full-year share 1, no other spells), not a fallback in production code. local_targets.py and the extractor are unchanged since round 1.
2 must resolved Rebased onto ed095db3b, which is #1344 on main after #1333. #1344 has moved one commit since (811f34668, diagnostics.py and its test only), with no overlap. Merges are clean against #1344's head and current main (54b514f92). Details below.
3 should open The body's commit 1 heading still says "with no change in output", and uk-local-metrics-from-declarations.changed.md still does not mention that the extractor's three high_value_homes/* columns move.
4 should open For when the local scorer runs; nothing to do in code.
5 should open The eFRS incumbent path still refuses with UC relationship benunit inputs missing: ['dependent_children'] (uc_relationships.py:38). The changelog names the register, but the error an operator sees does not.
6 should open _LOCAL_BINDING_KEYS still checks top-level keys only.
7 should open local_target_census.py:863, :869, :924 and :1113 still cite compute_household_metrics and income_tax > 0.
8 nit open The five selector notes still swap Tables 3.14 and 3.15.
9 nit open Notes on the self-employment count and employment income: unchanged.
10 nit open _local_metric_bindings cache: unchanged.
11 nit open _household_energy_cache: unchanged.

A correction to my round 1

Under "What looks right" I said commit 2 "matches HMRC's concept". That was too strong. The annual measures are each person's 12 months before their FRS interview, which is a proxy for the tax year, not the tax year itself. #1344's rebased changelog and this PR's body and changelog now say so plainly, and that wording is right. The direction of commit 2 still holds: it moves the local rows from a point-in-time read to a year of income, as HMRC counts. But it is a proxy, and I should have called it one.

What I ran

  • Fresh worktree at ab50a90bb, uv sync --all-packages --locked --extra uk.
  • The 8 test files the PR touches: 107 passed, 1 skipped, none failed. In round 1 the same files gave 2 failures.
  • The engine-free suites of microcosm-build and microcosm-data: 2 failures, the same 2 environmental ones as in round 1 (the orrery check needs Node packages, and the CGT test expects no engine). Nothing else failed.
  • ruff check . and python3 tools/ci_test_plan.py verify: both ok. CI still has no checks on this PR, as it targets uk-annual-income.
  • git range-diff of the round 1 commits against the new ones. Commit 1 changes only in the comment block that conflicted in round 1, where it now keeps UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's wording. Commit 2 adds the two fixture fixes and rewords the changelog and commit message to "a year of income". The declarations, the references file and local_targets.py are identical to round 1.
  • Does commit 1's parity still hold on the new base? The body says the base's resolver changes are docstrings and bus-fare pricing. I checked measure_simulation.py between the old parent (16a2cb836) and ed095db3b: apart from comments, the only change is engine_bus_pricing_parameters and the bus-fare alignment factors. None of the measures the local declarations read is touched. So my round 1 parity check on the a858c100 release carries over, and I did not rerun it.
  • git merge-tree against UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's head, current main and UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's head (829c1bc26).
  • Not run: no local or national build, no new data. I did not use the /review-program validators: this is a build-pipeline change, not a programme implementation.

Merge order and overlaps

  1. UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344 merges first. This PR is now on its history, one commit behind its head. The body's "rebased onto its head ed095db3b" is one commit stale; no need to rebase again, as the new commit touches no file here.
  2. This PR retargets to main. It merges cleanly with current main.
  3. UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338. Against UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338's head, this PR adds no conflict beyond the 14 that UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's base already has with it. The one in measure_simulation.py is the same import block in both cases: UK: childcare take-up at the spring term, under-2 take-up and single-age targets behind the £100k childcare costing (policyengine-uk 2.147.2) #1338 adds the DfE spring-term imports and this PR adds the energy imports next to UK #1334: draw UC take-up by award band, and measure the HMRC income rows over each person's year #1344's annual-income ones. Keeping both sides resolves it.
  4. Dashboard (uk-childcare-100k-threshold PR Register populace-uk 2023 (private; beats enhanced FRS 6/6) #8): no change from round 1. This PR touches only the local line.

Disclosure (small areas)

No change. The PR adds no output surface and the body reports parity without area or record figures. My view from round 1 stands: keep "don't publish local diagnostics" in force until the local-line ruling lands, since commit 2 changes who counts in five local rows and no carrier check covers those rows yet.

No new findings

The new code in this round is the two fixture changes. They give a full year at today's income, so the annual measures equal the current ones and the tests still check what they did on commit 1. The comments say why. Nothing else is new.

🤖 Generated with Claude Code

juaristi22 and others added 2 commits October 23, 2026 15:52
compute_household_metrics reads each constituency, local-authority and BRMA
metric's policyengine binding in uk_population_targets.json through the
national rows' binding interpreter (prepared_binding_values, now public), on
columns the measure resolver computes, and sums person and benefit-unit
bindings onto their household with the engine's map_result. The local and
national lines share one interpreter and one resolver, so a rebind moves
both. A local declaration that carries a key the local line does not honour
refuses. The full measure passes each block's resolver, and the incumbent
extractor builds one over the incumbent file.

Ten declarations change so they say what the code computed: the UC child
bands count UC benefit units by uc_calibration_child_count, the FRS-role
composition of the national UC-by-children rows; the private rents read
benunit_rent summed to the household; the high-value homes read the
owner_occupied_home_over_gbp_* measures; and the gas-connected households
read the new domestic_gas_connected, which reads the stored spend at the
dataset period as the kWh measures now do in the resolver. The interpreter
gains in lists, lower-inclusive ranges and subtraction, and refuses a
predicate that declares input_period or price_basis.

On a national frame the 61 metric columns of the three grains match the old
code bit for bit, except ons/equiv_housing_costs, which no active target
reads: the difference of the two incomes is now taken in float64, not
float32. The regenerated local references change only the entity they
record. The local tests drive the real resolver over a stub engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five local hmrc declarations (the self-employment and employment amounts
and counts and the higher rate taxpayers, by constituency and local
authority) read the annual measures for their values, their taxpayer test
and their rate band, as the national rows do
(self_employment_income_after_loss_relief_annual, employment_income_annual,
income_tax_annual and tax_band_annual). Target ids, facts, values and
metric names do not change. Now that the local line computes its metrics
from their declarations, the rebind is the declarations alone.

The incumbent extractor reads an incumbent without annual income
descriptors on its current values, as rule 1 scores it nationally (R1). An
engine test counts a leaver who paid tax on the year's spell in their area,
and the engine tests of the local energy and tax-band rows give their
frames full-year descriptors, as production frames carry them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 changed the base branch from uk-annual-income to main October 23, 2026 14:52
@juaristi22
juaristi22 force-pushed the uk-annual-income-local branch from ab50a90 to c0578bf Compare October 23, 2026 14:52
@juaristi22
juaristi22 marked this pull request as ready for review October 23, 2026 15:02
The changelog fragment's 2025 income tax and costing figures come from
the e399d21 arm, signed with a throwaway key, against #1338's certified
build. Say so, and say that current income lands below OBR's receipts on
this build rather than by construction (Vahid's items 25 and 26 on #1344).

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

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my third look, at e6ac8b157, now on main at 4cae4e426 after #1344 merged.

Verdict. The rebase is clean and changes no logic. Nothing in the code blocks this PR from our side. The two shoulds I asked for before merge in round 2 are still open, and both are wording only: item 3 (the body still says "with no change in output", and the changelog is silent on the extractor's three high_value_homes/* columns) and item 7 (the census fences still cite the old in_spi_frame definition and still say income_tax > 0). I'd still like those two in this PR, as each is a few lines. The rest can follow later. CI on e6ac8b157 was still queued when I wrote this, with the whole repo's queue backed up, so I can't say it passes yet.

Status of earlier items

# Tag Status Evidence at e6ac8b157
1 must resolved Unchanged since round 2. The two engine tests pass.
2 must resolved #1344 is merged and the PR targets main, sitting directly on its head. GitHub reports it mergeable with no conflicts.
3 should open Body line 9 still reads "with no change in output". uk-local-metrics-from-declarations.changed.md still does not say the extractor's three high_value_homes/* columns move on a microcosm-release incumbent.
4 should open For when the local scorer runs; nothing to do in code.
5 should open The extractor is unchanged; the eFRS refusal still names dependent_children, not the register.
6 should open _LOCAL_BINDING_KEYS (local_targets.py:301, checked at :336) still checks top-level keys only.
7 should open local_target_census.py:863, :869, :924 and :1113 are unchanged. The same text is in uk_local_target_census.json (lines 85, 89, 106 and 1131), so the fix is the fences plus regenerating the JSON with tools/census_uk_local_targets.py.
8 nit open The five selector notes still swap Tables 3.14 and 3.15.
9 nit open Unchanged.
10 nit open Unchanged.
11 nit open Unchanged (measure_simulation.py:1407).

What changed since round 2

What I ran

  • Fresh worktree at c0578bfaa; the new head e6ac8b157 adds only a changelog commit, so the runs below stand for it, uv sync --all-packages --locked --extra uk.
  • The 8 test files the PR touches: 107 passed, 1 skipped, as in round 2.
  • The engine-free suites of microcosm-build and microcosm-data: only the same 2 environmental failures as in rounds 1 and 2 (the orrery check needs Node packages, and the CGT test expects no engine).
  • ruff check . and python3 tools/ci_test_plan.py verify: both ok. The plan selects the UK integration tier.
  • Not run: no local or national build and no new data, as the logic is unchanged since round 2. I did not use the /review-program validators: this is a build-pipeline change, not a programme implementation.

Merge order and overlaps

#1329, #1332, #1333, #1338 and #1344 are all merged, and this PR sits on main with no conflicts. Your other open PRs (#1163, #1304, #1327) don't touch local_targets.py, the census fences or the extractor.

Disclosure (small areas)

No change. My view from round 1 stands: keep "don't publish local diagnostics" in force until the local-line ruling lands.

No new findings

🤖 Generated with Claude Code

juaristi22 and others added 2 commits October 23, 2026 17:11
Vahid's round 3 on #1347. The census fences cite the declarations
instead of the deleted in-code metrics: the HMRC area fence's rule is
income_tax_annual > 0, and the UC grain fence and family name the
FRS-role child count the bands read. The census JSON is regenerated, and
María's signed SPI-frame adjudication is re-worded to the annual frame at
both grains, its dates unchanged.

A local filter may carry only a column test; one that names an entity, a
concept or a reduction refuses. The incumbent extractor names
benunit.uc_calibration_child_count and the incumbent-unresolvable
register when an incumbent lacks the UC relationship inputs. The five
local HMRC notes cite Table 3.15 for constituencies and 3.14 for local
authorities, and say where HMRC's self-employment count and employment
income differ. The energy cache is set in the resolver's init, and the
bindings cache says how tests clear it. The changelog says the
extractor's high-value homes columns move on a release incumbent.

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

The fragment compared full-year UK-wide liabilities with HMRC's 2025-26
ready reckoner column, which differs on two counts. That column counts Self
Assessment tax when it is paid, so it holds little of a change's Self
Assessment effect; fitted to HMRC's later columns, a full year is 9% to 11%
higher. And its scope is the UK government's exchequer impact: it leaves
out the devolved Scottish and Welsh elements and adds the change in their
block grants (note 1).

On that scope and basis, in calendar 2025, the £1,000 allowance rise moves
from 3.1% to 1.1% above HMRC, and 1p on the basic rate, with the rUK
property income the engine's main-rate parameter misses, moves from 1.3%
to 2.9% below. Moved to 2025-26, both gaps are about 1.9% (microcosm#1370).

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

Copy link
Copy Markdown
Collaborator Author

@vahid-ahmadi thanks. Round 3 is addressed in 5a0c3f6d5 (commit 4) on top of e6ac8b157, and #1370 in 3ea2fd822 (commit 5).

# What changed
3 The body's commit 1 heading says "with no change in the build's local output", and a new bullet says the extractor's three high_value_homes/* columns move on a Microcosm-release incumbent, and why. uk-local-metrics-from-declarations.changed.md says the same.
7 The HMRC area fence's rule says income_tax_annual > 0, with its meaning, and its authority names the five hmrc declarations. The UC grain fence names the five dwp.uc.households_by_area* declarations, and it and the family's description now name uc_calibration_child_count, the FRS-role child count the bands read, not the old code's num_children. The bound_in_code status says the metric comes from its declaration. uk_local_target_census.json is regenerated with tools/census_uk_local_targets.py: those six lines only. On María's ruling today, her signed hmrc_spi_frame_model_proxy adjudication is re-worded to the annual frame at both grains, with its approval and expiry dates unchanged.
5 The extractor catches the UC relationship refusal and names benunit.uc_calibration_child_count and uk/incumbent_unresolvable_measures.json. It also says that the local extractor does not prune listed measures. The prune itself stays under "Not in this PR".
6 _LOCAL_FILTER_KEYS allows variable, operator, value, equals, in, lower and upper, and a filter with any other key refuses. Three new cases (entity, reduce, concept) are in test_a_local_metric_the_local_line_cannot_read_refuses.
8 The five local hmrc notes cite Table 3.15 for constituencies and Table 3.14 for local authorities.
9 The self-employment count note says HMRC counts every taxpayer with a self-employment source. The two employment notes say HMRC's employment income includes taxable benefits and the three contributory benefits. The national rows carry the same gaps; their notes can follow.
10 A comment on _local_metric_bindings says a test that patches the contract clears the cache before and after.
11 _household_energy_cache is set in UKMeasureResolver.__init__.
4 No change: for when the local scorer runs.

Tests. The four engine-free files that the change touches, or that read the changed registers: 105 passed. ruff check and ruff format --check: ok. CI runs the rest.

#1370 (commit 5). The personal allowance gap is mostly not the model's. HMRC's 2025-26 column counts Self Assessment tax when it is paid, and it is on the UK government's scope (note 1). Like for like, the allowance rise is 1.1% above HMRC and 1p is 2.9% below, in calendar 2025; moved to 2025-26, both gaps are about 1.9%. The model's taxpayers by marginal rate are within 1% of HMRC's, so no calibration change follows. Commit 5 puts the changelog's comparison on that basis, and the split is on #1370. The 1p reform misses rUK property income, which is an engine issue, filed as PolicyEngine/policyengine-uk#2607.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my fourth look, at 3ea2fd822 (commits 4 and 5 on top of round 3's e6ac8b157), on main at 4cae4e426.

Verdict. Every round 3 should and nit is resolved at this head, and I checked each one in the code, not just the reply. Nothing in the code blocks this PR from our side. Item 4 stays open by design, for when the local scorer runs. Commit 5's HMRC comparison matches the January 2025 bulletin, and its arithmetic holds. I have two nits on how settled the full-year figures sound (items 12 and 13), both wording only. CI has not given a full result on any head since the PR moved to main. On 3ea2fd822, lint, wheels and select-countries pass, but the engine-free, engine-uk, engine-us and integration-uk jobs were all cancelled partway through their test steps, as every job on e6ac8b157 was. So the merge waits on a green rerun.

Status of earlier items

# Tag Status Evidence at 3ea2fd822
1, 2 must resolved Unchanged since round 3.
3 should resolved The body's commit 1 heading now says "with no change in the build's local output". A new bullet says the extractor's three high_value_homes/* columns move on a Microcosm-release incumbent, and why. uk-local-metrics-from-declarations.changed.md says the same.
4 should open, by design For when the local scorer runs. Nothing to do in code.
5 should resolved tools/extract_uk_local_incumbent_surface.py catches the UC relationship refusal and names benunit.uc_calibration_child_count and uk/incumbent_unresolvable_measures.json. The new test raises through the real frs_uc_claimant_mask, so the text it matches can't drift. It fails on round 3's code.
6 should resolved _LOCAL_FILTER_KEYS (local_targets.py:315) allows only variable, operator, value, equals, in, lower and upper. The three new cases (entity, reduce, concept) fail on round 3's code and pass here. I also loaded the packaged contract under the new guard: all 22 constituency, 38 local-authority and 1 BRMA bindings load, so no real local declaration trips it.
7 should resolved The fences at local_target_census.py:864, :921 and :1123 and the uc_households description now cite the declarations, income_tax_annual > 0 and uc_calibration_child_count. tools/census_uk_local_targets.py --check passes, and a fresh --out is byte-identical to the committed uk_local_target_census.json. The adjudication re-wording follows the register's usual dated "re-worded" note, and the rowwise solve reads only the register's dates, not its text.
8 nit resolved The five notes now cite Table 3.15 for constituencies and 3.14 for local authorities.
9 nit resolved The self-employment count note and the two employment notes now say where HMRC's concept differs.
10 nit resolved There is a comment above _local_metric_bindings.
11 nit resolved _household_energy_cache is set in UKMeasureResolver.__init__ (measure_simulation.py:939).

Commit 5 and #1370

I checked the comparison against HMRC's "Direct effects of illustrative tax changes" bulletin (January 2025):

  • The 2025-26 figures match: £6,550m for 1p on the basic rate, and £780m, £980m and £9,650m for the £100, 1% and 10% allowance rows.
  • Note 1 says what the changelog says. Income Tax figures are the UK government's exchequer impact: England and Northern Ireland receipts, plus the change in the Scottish and Welsh block grants, without the devolved elements.
  • The bulletin's notes say Self Assessment Income Tax is scored when HMRC receives it, and note 3 says the figures include behaviour.
  • The arithmetic holds: £8.54bn against about £8.45bn is 1.1% above, £8.71bn is 3.1% above, and £7.07bn against £7.28bn is 2.9% below. The 1p rows add up: 6.51 + 0.10 + 0.46 = 7.07, plus 0.07 for the year gives 7.14.

I could not check the model-side split (the Scottish and Welsh block-grant terms, the taxpayer counts, the taper zone) without a build, so I have not.

12. nit: the full-year uplift is a fit, and the changelog reads as if it were a published figure. HMRC's three columns don't pin a full year on their own. As a rough check, I took the 2027-28 column as a full year and deflated it to 2025-26 at your own 2.4% to 3.8% a year. That gives about +9% to +12% for the 1% allowance row, +7% to +10% for the 10% row, and +11% to +14% for 1p. Your 9.0%, 9.8% and 11% sit inside those bands or at their edge. But the bands are a few points wide, about as wide as the ±1.9% residuals. The £100 row is also out of line with the 1% row in the later years (£1,100m against £1,150m for about £126 in 2027-28), which is presumably why you left it out. Please either state the fit in #1370 (which columns, what Self Assessment share and growth), or give the changelog's "about £8.45bn" and "£7.28bn" as ranges. Then "like for like, about 1.9%" reads as within the fit's uncertainty, not as a measured gap. The fragment is unreleased, so this can go in any later commit.

13. nit: £0.09bn or £0.10bn. PolicyEngine/policyengine-uk#2607 says moving rates.property.basic by 1p adds £0.09bn. #1370's table and the changelog's £7.07bn use +£0.10bn. Presumably these are two roundings of one number, so one of them could say which.

What I ran

  • A fresh worktree at 3ea2fd822, then uv sync --all-packages --locked --extra uk.
  • The 8 test files the PR touches: 111 passed and 1 skipped (the OA ladder artifact isn't present). That is round 3's 107 plus the four new cases. I put round 3's local_targets.py and extractor back and the four new cases failed, so they test the fixes.
  • The engine-free suites of microcosm-build and microcosm-data: about 17,600 passed, 133 skipped, 3 xfailed and 2 failed. The 2 failures are the same environmental ones as in rounds 1 to 3 (the orrery check needs Node packages, and the CGT test expects no engine).
  • ruff check . passes. ruff format --check passes on the PR's 15 Python files (45 other files in the repo would reformat, all already on main). python3 tools/ci_test_plan.py verify passes, and the plan selects the UK engine and integration tiers.
  • main has moved once since round 3, to 6bdb8001c (Re-pin the NZ Chronicle feed to chronicle main 8f4cfd57 (G8-d3 earnings) #1367, an NZ Chronicle re-pin). It touches none of this PR's files, and git merge-tree against it is clean.
  • Not run: the engine and integration tiers in full (only the three engine files the PR touches, which are in the 111), any build, and any new data. CI is the only check on those, and its jobs for them were cancelled. I did not use the /review-program validators, as in round 3: this is a build-pipeline change, not a programme implementation.

Disclosure (small areas)

No change. My view from round 1 stands: keep "don't publish local diagnostics" in force until the local-line ruling lands.

🤖 Generated with Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants