Skip to content

UK dense/local epic: rowwise constituency-grain dataset by cloning the national staging pool (US dense-arm analog) #495

Description

@MaxGhenis

Max greenlit scoping the "bigger UK dataset" arm 2026-07-22. This epic is the UK analog of the US dense/local arm: one national rowwise artifact whose rows carry finest-available geography and whose weights are calibrated against a constituency/local-authority target surface — built by cloning the compact FRS-based pool, because the UK has no ACS-scale microdata to substitute for density. It subsumes the publication goal of #146 (rowwise H5 + long weights), sits under the #144 migration umbrella, and is the sibling of #355 (the pool should get a smaller national default at the same time as this arm gets bigger).

Everything below is designed against the machinery that already exists (uk_runtime/rowwise_geography.py, rowwise_dataset.py, geography_ladder.py, local_targets.py, local_solver.py, local_runner.py, tools/build_uk_rowwise_dataset.py); the epic is mostly wiring, fences, and targets, not new solvers.

Base artifact and pipeline order (fence-determined, not a preference)

The clone input is the national staging H5 — the output of the national orchestration seam after the 145/0 input-coverage gate and the adjudicated HMRC/SPI replay — not the raw certified compact:

certified compact (f17306cc…) national staging (829e843f…)
households 535,080 (200,000 zero-weight dead SPI) 535,080 (rebuilt SPI channel, positive mass)
persons 1,157,100 1,157,739 (724,960 FRS + 432,779 SPI)
benunits 618,980 618,884
household mass 28,840,551.182 28,840,551.182 (conserved; 1 mass-change record)
weight kind (unlabeled) importance + mass log in H5 attrs
bytes 1,315,880,118 1,532,379,785 (local_untracked)

(Compact figures verified directly against the cached certified H5; staging figures from the committed national_staging_build_record.json.)

The ordering is forced by the fences in hmrc_restoration.py: the HMRC replay byte-binds its input to the certified candidate (_assert_dataset_matches_certified_candidate checks the source-file fingerprint captured at SHA-256 verification), so cloning can never run upstream of the seam, and cloning the certified compact directly would clone 200k dead SPI households and skip the 145/0 restoration. Pipeline:

certified compact (byte-verified)
  → national seam (retained FRS leaves → HMRC/SPI replay, IMPORTANCE weights, mass log)
  → staging H5  ← the 145/0 gate passes here
  → rowwise clone + geography ladder assignment      (this epic)
  → one uniform local+national calibration solve      (this epic)
  → rowwise release, private HF only                  (this epic)

Clone count K: rows, memory, support

The pool is already a 10× clone: household_id = tier·10⁸ + base with exactly 53,508 unique base ids × 10 tiers (verified on the certified H5; #355 documents the construction). eFRS-grain source lineage is therefore recoverable as household_id mod 10⁸, and the rowwise build must carry it — per-area "distinct source households" diagnostics computed at pool grain would overstate real support 10×.

K below means the additional rowwise multiplier on top of that 10×. Clones add geographic resolution and solver degrees of freedom only — zero new information — so K is sized by per-area support, not ambition:

K households persons H5 (≈, linear) mean rows/constituency (650) smallest constituency (≈)
1 535,080 1,157,739 ~1.5 GB 823 ~240
2 1,070,160 2,315,478 ~3.1 GB 1,646 ~480
4 2,140,320 4,630,956 ~6.1 GB 3,293 ~960
10 5,350,800 11,577,390 ~15 GB 8,232 ~2,400

The smallest-constituency column is the population-proportional expectation for a Na h-Eileanan an Iar-scale area and is approximate; the dry-run tool (increment 2) computes the exact expected support per constituency from the ladder/crosswalk populations and the pool's region mix. For calibration comparison: the US dense arm supports 436 CDs on a 337,704-record pool ≈ 775 records/CD (#355, #449) — K=1 is already at US-dense per-area scale, because the 10× pool cloning did the density work up front.

Proposal: pilot at K=1 (geography assignment only, no row growth), first published candidate at K=2–4, chosen from the dry-run support/ESS diagnostics. K>4 requires a measured justification. avoid_constituency_collisions stays on (no source household repeated in the same constituency across rowwise clones).

Geography assignment: the OA ladder is the operator

Two mechanisms exist. The crosswalk path (#142/#143) samples an OA within (country, region) ∝ population and covers all four countries. The OA ladder (#349 ratified, #354 merged) samples a 2024 Westminster constituency within the region ∝ census household counts, then an OA within the constituency ∝ population, carries sha-pinned best-fit lookups and per-layer vintages (vintage_policy: error), and ships a release-blocking gate — but is England & Wales only today (575 of 650 constituencies).

The epic declares the ladder as the assignment operator (it is the ratified US-block-ladder parallel with vintage discipline). Work items:

  • extend the ladder artifact to Scotland (2022 census OAs, NRS sources) and Northern Ireland (Data Zones 2021, NISRA) — geography_sources.py already pins those source URLs for the crosswalk path; they need per-layer vintage pinning in oa_ladder_sources.py;
  • until S/NI land, a full-UK rowwise build through the ladder fails loudly on the missing regions (already the ladder's contract — no silent partial coverage);
  • rewire tools/build_uk_rowwise_dataset.py from the crosswalk sampler to assign_uk_geography_ladder + uk_geography_ladder_gate, keeping the crosswalk as the coverage-check path.

Local target surface: census first, then bind

local_targets.py computes today, at household grain:

  • constituency: HMRC employment/self-employment amount+count (SPI frame via income_tax > 0), age bands (8 × 10y), UC households, UC households by child count (0/1/2/3+);
  • local authority: the HMRC/age/UC families plus ONS equivalised net income BHC/AHC, housing costs, tenure (4 classes), private rent.

That mirrors the deprecated uk-data constituency surface. What official UK local data offers beyond it (ONS mid-year age structure by PCON24/LAD; HMRC Personal Incomes by constituency — taxpayer counts and employment/self-employment/pension totals; DWP Stat-Xplore UC/Pension Credit/Housing Benefit by constituency and LA; Census 2021/2022 tenure and composition; ONS PIPR rents by LA; ONS small-area income at MSOA) needs a census artifact, not prose: increment 1 ships a tool that enumerates every in-code metric family and every documented official family with area type, source, vintage, and status (bound_in_code / documented_unpinned / needs_adjudication), checked in and tested, so binding work is enumerable and claimable per family.

Two standing fences on targets:

  • the 208 banded HMRC facts stay fenced (see below) — the constituency HMRC family is a different, unbanded surface and gets its own admissibility adjudication before it binds;
  • the income_tax > 0 SPI-frame membership in the HMRC metrics is a modeled proxy frame; the census must record that honestly and the binding adjudication must accept or replace it explicitly.

The local solve: one uniform operator, declared bounds (doctrine)

Per the #492 lane and #493: no per-target calibration knobs. The solve is one operator over the whole surface — national families (149 targets in the published release, plus the natively declared CGT facts) and all area×metric rows together — with globally declared loss shape, cap, and weight-ratio bounds. A miss is local-support work (rows/clones/ladder) or target work (fix or fence the target), never a knob.

HMRC IMPORTANCE-weight fences under cloning

Read against hmrc_restoration.py, cloning survives the fences honestly if and only if the metadata rides along:

  1. Replay-before-clone is structural — the replay's byte-binding to the certified candidate means the clone consumes the seam's output; the fence cannot be reordered away.
  2. Mass conservation is exact under cloning: weights divide by K, every row duplicates, so total mass and every column's effective-mass share are invariant by construction. The 145/0 gate still re-runs on the rowwise output (fail-closed, never assumed), and the SPI channel's reviewed 50% mass share is re-checked post-solve as a declared bound.
  3. Live gap (increment 2 fixes): rowwise_dataset.py reads and writes only the four entity tables — it drops the staging H5's populace_household_weight_kind (importance) and populace_mass_log_json attrs. Cloning the staging H5 through today's tool silently launders IMPORTANCE weights into unlabeled weights. The clone step must carry both attrs, refuse an input whose weight kind it does not recognize, and assert exact mass conservation against the input.
  4. No calibration against the 208 banded facts, before or after cloning: complete FRS Total Income band assignment remains unavailable, so those facts remain reviewed exclusions with canonical fences in every rowwise release exactly as in the national seam. Nothing in this epic touches IMPORTANCE-weight replay internals, the reviewed release parameters, or the source contracts.
  5. The local solve transitions weight kind explicitly (importance → locally calibrated) as a recorded step in the mass log, never implicitly.

Release contract for the rowwise artifact

The 145/0 contract covers input columns and carries over unchanged (the gate re-runs on the rowwise output). The rowwise artifact additionally promises, in its manifest:

  1. identity chain: staging H5 sha → certified candidate sha → source-stage shas (donor/ODS identities by reference to the committed replay artifacts, never row-level data);
  2. geography: ladder artifact sha + per-layer vintages, 650/650 (or the declared E&W 575 subset) constituencies assigned, zero missing-geography rows, ladder gate pass;
  3. weights: exact mass conservation vs staging, declared weight-kind chain (importance → locally calibrated), carried mass log, declared stretch bound;
  4. solve: uniform-operator config (loss shape, cap, ratio bound, epochs, seed), per-family fit, past-cap census, per-area support with eFRS-grain distinct-source counts;
  5. role: dataset_role: non_default_local_area per Release contract needs dataset-role classes: national-default checks reject valid non-default local-area artifacts #398 — never the latest.json pointer (--no-latest), immutable tag, the compact (and eventually the UK small national default: L0-selected ~50k-household file from the 535k pool #355 small default) stays the national default. Until Release contract needs dataset-role classes: national-default checks reject valid non-default local-area artifacts #398's role-class validator lands, publication uses the Build-L routed path (all applicable gates recorded).

Publish mechanics (licensing)

FRS-derived microdata must never reach a public repo. The rowwise H5 and every sidecar that carries household-grain rows publish exclusively to the private HF dataset repo policyengine/populace-uk-private (the certified compact's home), via populace-publish-release --repo-id policyengine/populace-uk-private --no-latest, tagged populace-uk-2023-rowwise-<git>-<ts>. Aggregate diagnostics (manifest, fit tables, support summaries, past-cap census) are committable to the repo exactly like the HMRC replay artifacts — no row-level donor or FRS data, no local paths.

Increment ladder

  1. Local-target census tooltools/census_uk_local_targets.py + checked-in census JSON + tests. Pure observability. (claimed, this lane)
  2. Rowwise fence carriage + dry-run — carry weight-kind/mass-log attrs through clone read/write, refuse unrecognized weight kinds, assert exact mass conservation in the driver manifest, and add --dry-run: the full clone plan (rows/bytes at K, exact expected per-constituency support from crosswalk populations × the pool's region mix, id-multiplier and lineage checks) with no H5 written. (claimed, this lane)
  3. Ladder S/NI extension — pin NRS/NISRA sources with per-layer vintages; full-UK ladder artifact.
  4. Solve doctrine hardening — single-operator config surface, declared stretch bound (max_weight_ratio anchors differently per arm: 5x vs design (dense) but ~25x effective (sparse refit re-anchors) — declare one stretch contract #493 alignment), past-cap census port (Loss shape: flat cap at 1.0 makes past-cap rows free dumping grounds (17 rows pushed out in Build N); bounded-tail experiment + past-cap census diagnostic #492), structural refusal of per-target knobs.
  5. K=1 pilot build on the real staging H5 (licensed machine): geography + diagnostics, no solve; adjudicate K from the measured support.
  6. First calibrated rowwise candidate (K per adjudication): bound target families only, full contract above, cross-family review, private publish.
  7. Loader/consumer wiring (policyengine.py) — out of scope here; coordinate on the campaign index.

Refs: #144, #146, #355, #349, #354 (ladder), #420 (145/0 contract), #398 (dataset roles), #492/#493 (solve doctrine), #275 (one national dataset, filter by geography), #142/#143 (crosswalk + driver), UK_COVERAGE_PROGRESS.md (fence history).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions