UK source packages, wave 3: local geography - #163
Conversation
…e#134) Wave 3's first package and the mechanics pilot the issue asks for: ONS mid-2024 population estimates for the 575 England and Wales constituencies on July-2024 boundaries, at the publisher's quinary age bands, from the pinned Nomis API response (dataset NM_2014_1, long format, one row per constituency and band). 10,925 facts. Row-count mechanics, confirmed on a 650-row-scale table and now available to the rest of the wave: one record set per publisher band with the band's filters and constraints declared once in shared_filters/shared_constraints, rows carrying only geometry (row_number, GSS-code row header guard, per-row geography override) and a band guard. That is ~16 YAML lines per fact; the alternative orientation costs ~26. The ten-year bands the uk_local_geography profile declares are carried by record_set_spec_id, which two quinary record sets share, so the profile's sum-only operation adds exactly the two publisher facts per constituency. The spec ids name no publisher, so the NRS and NISRA legs join the same groups. Proven on the built facts: each of the eight ten-year groups resolves to exactly two facts for all 575 constituencies and sums to the artifact. ONS publishes quinary groupings, not ten-year ones, so banding at download stops at five years; the ten-year step is a declared sum, never arithmetic in this repo. The 18 bands sum to the published All Ages total for every constituency, so the ported set partitions the resident population; 80-84 and 85+ have no profile target and are ported so no partial population ships. Three lane changes this needed: - ALLOWED_GEOGRAPHY_LEVELS carried an unused `parliamentary_constituency` while the profile and populace's runtime both say `constituency`. Renamed rather than adding a synonym; nothing emitted the old name. - _source_row_age_range now reads Nomis's "Aged 85+" as the open top band, the same statement as the "and over" wording it already handled. - source_cells_from_xlsx takes an optional sheet allowlist. Multi-geography publisher workbooks carry sheets a package never selects from and every one of their cells costs a record per build; the NRS Scotland workbook that lands next is 963k cells of which the constituency sheet is 5%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd (PolicyEngine#134) The uk_local_geography profile declares its age targets over all 650 constituencies, and the ONS/Nomis small-area dataset stops at England and Wales, so the family needs the devolved statistical agencies: - nrs-pcon24-population-by-age-2024: the 57 Scottish seats by single year of age, from the NRS special area tables (UKPC sheet, mid-2024). 5,244 facts. - nisra-pcon24-population-by-age-2024: the 18 Northern Ireland seats at five year age bands, from the NISRA PxStat CSV response for table MYE01T013. 360 facts. 543 England + 32 Wales + 57 Scotland + 18 Northern Ireland = 650, every one of them resolving under all eight ten-year record_set_spec_id groups. The grain differs by publisher - quinary in England and Wales, single year in Scotland, five year bands in Northern Ireland - because each agency publishes its own; the spec-id grouping absorbs that without either package restating the other's classification. Verified by re-derivation against both artifacts: every fact equals its cell, no cell dropped or invented, the bands partition each area's published total, and NISRA's own Northern Ireland total (1,927,855) equals the sum of the 18 seats to the person. Not ported, deliberately: the Males and Females rows and the 2021-2023 years in the NISRA table (no target needs them, and a partial sex split reconciles to nothing), and its N92000002 country row, which the national mid-year estimate packages already carry - putting two publishers' facts in one country series would make the resolver arbitrate between them. Two lane additions the legs needed: - The NRS workbook is 963k cells across sixteen geography worksheets, of which the constituency sheet is 5%. The artifact declares `sheets: [UKPC]` so the rest are not parsed into cell records on every build; the package builds in 21 seconds. - _source_row_age_range now reads NISRA's "Five year age bands" column, so its bands evidence the same numeric age bounds the other legs carry. The code column is deliberately not in that list: its values are category numbers that would parse as ages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ONS mid-2024 population estimates for all 361 United Kingdom local authorities at five year age bands, from the pinned Nomis API response for dataset NM_31_1. 7,220 facts, every authority resolving under all eight ten-year record_set_spec_id groups. This is the one dataset that spans the whole UK at this grain - it carries the 11 Northern Ireland local government districts alongside the English, Welsh and Scottish authorities - so unlike the constituency level, the local-authority level needs no devolved legs. Reference period is mid-2024, matching the constituency packages, so the whole age family answers at one period and a consumer reads across both levels without declaring a period alignment. Nomis publishes mid-2025 for this dataset too; that vintage is a later addition, not a replacement. NM_31_1 splits its first quinary band into "Aged under 1 year" and "Aged 1 - 4 years" where the constituency legs carry a single 0-4 band. Both are publisher partitions of the same ages and both sum into the profile's 0-10 group - the case record_set_spec_id exists to absorb. _source_row_age_range now reads the "under N years" wording so that band evidences its bounds like every other. Verified by re-derivation: every fact equals its artifact cell, nothing dropped or invented, and the 19 bands sum to the published All ages total for all 361 authorities. Coverage note against policyengine-uk-data: its committed LA age CSV carries 360 areas to this artifact's 361. The delta is enumerated when the parity fixture lands with the checklist update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…olicyEngine#134) The harness doc now carries the row-count mechanics the issue asked to confirm: one record set per publisher category rather than per geography, geometry-only rows, and category semantics in shared_filters/shared_constraints so they are declared once instead of on every geography row. Also records the two rules that bite at this scale and are enforced by agent acceptance rather than validate-package - row-backed filters must be evidenced by the parsed row's own columns, and geography-only rows are totals - plus the digit-header guard trap and the new `sheets:` allowlist. The checklist's wave-3 section becomes the same full-inventory table the national families use, with status, package and PR columns. Parity for these families is measured against uk-data's local loss matrices, not its target registry, because the local modules emit no registry Targets. Two dispositions worth reading before the next batch: - ASHE constituency earnings are excluded (computed): the committed NOMIS workbook feeds an interpolated band distribution that loss.py never reads, so it is not a target input at all. - The devolved constituency rent anchors are blocked with no source. Wales at 200,700 households and £795/month, Scotland at 357,706 and £999/month, are hardcoded in uk-data with no citation, then allocated across constituencies by population share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Batch 2: income and tax by area from the collated Personal Incomes Statistics geography tables for tax year 2023-24. Table 3.15 is the Westminster constituency cut on 2024 boundaries, Table 3.14 the borough, district and unitary authority cut; between them they carry the same 650 constituencies and the local authorities the population packages do, plus 27 counties, 9 regions and 3 countries. 19,816 facts over 19 measures. Publisher scaling is applied as the worksheets state it: counts are in thousands and the total-tax amount in GBP millions, so those measures carry value_scale; means and medians are in pounds and carry none. Each measure takes its own aggregation - a median is not a sum. Three things the artifact forced, each a general lesson for the wave: - **No income-amount column exists.** HMRC publishes counts, means and medians per income type and an amount only for total tax. The profile's hmrc.employment_income.amount and hmrc.self_employment_income.amount targets therefore have no publisher fact at all; uk-data forms them as count x mean, which is excluded (computed). Those two targets are now a profile decision, not a sourcing gap. - **"[Not available]" is not a value.** HMRC suppresses the additional-rate count in 98 areas and more in a few others. Rows are grouped by which measures they actually carry and each group becomes a record set holding exactly those, so a suppressed cell shows up as the difference between groups rather than as a zero or a null. Isles of Scilly is suppressed in every column, so it yields no facts - the generator reports that rather than letting a geography vanish quietly. - **Both worksheets restate the same 9 regions and 3 countries cell for cell.** Those are one fact each, so they are ported from Table 3.15 and skipped on 3.14; the first attempt collided on fact_key, which is fact identity working as designed. Verified by re-derivation against the ODS: 19,816 published cells, 19,816 facts, no cell dropped, none invented, every value matching after scaling. Not ported: the 3.12, 3.13, 3.13a, 3.14a and 3.15a worksheets - percentile and confidence-interval restatements of the same survey that no declared target needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Batch 3: households on Universal Credit at May 2025, tabulated by area on the statxplore_table_json_rows lane wave 2 established. Three packages, 6,038 facts: - by Westminster parliamentary constituency (632) - by local authority (350) - by constituency and number of children (5,056) Stat-Xplore carries a first-class PCON24 field, so the constituency cut is on 2024 boundaries with GSS codes in the value URIs - the same 2024 geography the population and SPI packages use. This replaces policyengine-uk-data's by-children split, which applies November-2023 country shares to 2025 GB totals: excluded (computed), because the publisher tabulates constituency by children directly. Coverage is Great Britain. DWP's UC official statistics do not cover Northern Ireland, where the Department for Communities publishes separately, so the 18 Northern Ireland constituencies and 11 districts are a signed exclusion for this family - enumerated on PolicyEngine#134, not silently dropped. Two publisher facts that are real but not geography-keyed, so recorded here rather than forced into a geography: - The "Unknown" area row: 7,054 households in each of the two totals cubes, and 7,058 across the children cube. - Cross-cube marginals do not reconcile exactly, as wave 2 found nationally. 571 of 632 constituencies differ between the households cube and the sum of the children cube, median 4 households and at most 15; GB totals are 6,577,205 against 6,576,846. DWP applies cell-level adjustment at source, so each cube carries its own publisher-emitted values and neither is corrected toward the other. The local-authority cut is taken through the ward-based geography field: the output-area field carries the same Local Authority valueset but times out server-side, and the two are the same published level. Verified by re-derivation against each API response: cell counts and fact counts equal, nothing dropped or invented, every value matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) Both families move off "next" with their package lists, and with the two findings a reader needs before trusting them: the SPI tables publish no income-amount column, so the profile's two amount targets have no publisher fact at all; and the UC family is Great Britain only, so Northern Ireland is a signed exclusion rather than missing data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ine#134) The children package reused `universal_credit_households` as its measure id, so the profile's dwp.universal_credit.households target - which selects that source_measure_id - would have matched all 5,056 children facts alongside the 632 totals and summed them, roughly doubling every constituency. Renamed to `universal_credit_households_by_children`. The four buckets the profile declares (0, 1, 2, 3+) are now carried by record_set_spec_id, the same mechanism the ten-year age bands use: DWP publishes 0 to 5 plus "5 or more", so 3, 4 and "5 or more" share the 3plus spec id. Its two non-count categories - "Not available prior to April 2019" and "Unknown or missing" - keep their own spec ids: they are publisher facts, but they are not a family size and no target should sum them into one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One measured build of the whole tree, with every pinned structure regenerated from it rather than patched entry by entry: the summary counts dict, by_source, by_period and by_entity as whole literals, the five scalars, and the per-geography pins verified against the build rather than assumed unchanged. 44,867 facts to 94,470, which is exactly the 49,603 the eight wave-3 packages carry. 1,068 geographies to 2,106 as the 650 constituencies and 361 local authorities arrive; 96 packages to 104; 37 sources to 39 with nrs and nisra. by_source moves on dwp (289 to 6,327), hmrc (717 to 20,533) and ons (5,117 to 23,262). Period count is unchanged at 116 - every new fact lands in a period the bundle already had. country:K02000001 stays at 3,820: wave 3 adds no UK-national facts, only sub-national ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…licyEngine#134) Batch 4 needs three lane capabilities the repo did not have. **xlsx_table_full_rows.** The PIPR monthly workbook is one ~49,000-row sheet crossing every month since 2015 with every area and property size. Its used-range cell parse is ~2M cell records per bundle build, which is what disqualified the workbook on PolicyEngine#159. This applies the delimited full-row contract to a worksheet: the whole table is preserved as source rows keyed by the header line's columns, and the artifact's selected_rows restrict cell emission. The PIPR package emits its 348 facts from 13,960 cells instead of ~2M, with every row still preserved and queryable. **header_row.** Export tools write a metadata preamble above the table - NRS's SuperWEB2 export puts ten lines above the header. Lines before the declared header row are preamble, not data. Both full-row lanes take it. Note for authors, learned the hard way here: source_cells_from_source_rows renumbers preserved rows virtually (header 1, first parsed row 2), so a package whose artifact has a preamble must cite the VIRTUAL row, not the physical file line. They coincide only when the header is line 1. The first UV404 attempt cited physical lines and the tenure-category guard caught it, ten rows off - the guard doing exactly its job. **msoa geography level**, for the ONS small-area income estimates: MSOA is the only grain that publisher releases. Datetime cells flatten to ISO text so row criteria and guards compare against a stable string form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six packages, 37,100 facts, completing the local income, tenure and rent families. | package | facts | grain | |---|---|---| | ons-small-area-income-msoa-fye2023 | 29,056 | 7,264 MSOAs x 4 measures | | ons-census2021-ts054-tenure-lad | 2,862 | 318 E&W districts x 9 categories | | nrs-census2022-uv404-tenure-council-area | 288 | 32 Scottish councils x 9 | | nisra-census2021-tenure-lgd | 121 | 11 NI districts x 11 categories | | ons-subnational-dwellings-by-tenure-2024 | 4,425 | 295 English districts x 5 x 3 years | | ons-pipr-rents-by-area-june-2026 | 348 | 348 areas at June 2026 | **Tenure is UK-wide, and the household and dwelling series stay apart.** The three census legs carry publisher-specific classifications - Scotland has one social-rented category where England and Wales has two, Northern Ireland splits private renting five ways, and Scotland's "Shared Equity (LIFT or Help-to-Buy)" has no counterpart at all. Concepts that genuinely match share a publisher-neutral record_set_spec_id (uk.local_geography.tenure.<bucket>.v1) so one profile target sums its bucket across legs; categories with no cross-leg match keep their own bucket rather than being forced into one. SPREE is the same tenure language over a different entity, so it carries entity dwelling and its own uk.local_geography.dwellings.* spec ids: a target pinned to entity household can never tie-break against it. That was the separation requirement on PolicyEngine#134. **The Scottish census table does not add up, by design.** NRS applies disclosure control cell by cell: in 26 of 32 councils the eight detail categories differ from the published all-households total by -6 to +10 households (median 3), and the roll-ups differ from their own details in 17 councils. Scotland-wide the gap is 8 households in 2,509,275. Every value is carried as published and nothing is reconciled - unlike the England and Wales leg, where TS054's details sum to the total in all 318 districts. The package header states which leg behaves which way. **Two more profile targets have no publisher fact.** ONS publishes small-area income at MSOA only, with no local-authority cut, so the three ons_income targets move to geography_level msoa; and there is no equivalised-housing-costs measure at all - uk-data forms one as BHC minus AHC, excluded (computed). So ons.equiv_housing_costs joins the two hmrc amount targets as declared-but- unbacked, all three for the profile-correction PR. Enumerated exclusions: publisher roll-up categories that are derivable sums of ported details (they would double-count inside the buckets); confidence-limit and percentage restatements; the Northern Ireland rent rows, which publish "[x]" at June 2026 because NI lags the E&W series - the Dec-2025 NI level is already ported in the wave-2 bulletin package; City of London and Isles of Scilly, absent from the publisher's rent table. Scotland's export carries council names only, so each row's S12 code is authored from the publisher name - cross-checked 32/32 against the batch-1 LA age package, which carries both - and guarded by that exact name as the row header. Verified by re-derivation against every artifact: fact counts equal published cell counts, nothing dropped or invented, every value matching. The dropped Scottish roll-ups were confirmed to be publisher sums of ported details, so the exclusion loses no information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The income, tenure and rent rows move off "next" with their package lists. Three things a reader needs before trusting them: ONS publishes small-area income at MSOA only and publishes no housing-costs measure at all, so ons.equiv_housing_costs has no publisher fact; the tenure family is now UK-wide across three census legs whose classifications differ; and the PIPR row is no longer "at risk" because the new full-row lane made its parse affordable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One measured build of the whole tree, every pinned structure regenerated from it: the summary counts dict, by_source, by_period and by_entity as whole literals, the five scalars, and the per-geography pins checked against the build rather than assumed. 94,470 facts to 131,570, exactly the 37,100 the six batch-4 packages carry. 2,106 geographies to 9,390 as the 7,264 MSOAs and 18 Scottish rental market areas arrive; 104 packages to 110; 105 source tables. by_source moves on ons (23,262 to 59,953), nrs (5,244 to 5,532) and nisra (360 to 481). Period count gains one, month 2026-06, the PIPR reference month. Two per-geography pins moved by exactly one each: country:K02000001 3,820 to 3,821 and country:K03000001 276 to 277. Those are the UK and Great Britain average-rent rows PIPR publishes alongside its area rows - the only UK-national facts this wave adds, and the reason the pins are verified against the build instead of assumed to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…x stock (PolicyEngine#134) Six packages, 3,860 facts, closing the two families wave 3 had left. | package | facts | grain | |---|---|---| | ons-census2021-ts041-households-pcon24 | 575 | E&W constituencies | | ons-census2021-ts041-households-lad | 318 | E&W districts | | nrs-census2022-households-ukpc24 | 57 | Scottish constituencies | | nisra-census2021-households-pcon24 | 18 | NI constituencies | | nisra-census2021-households-lgd | 11 | NI districts | | voa-council-tax-stock-by-lad-2025 | 2,881 | 318 districts x 10 band measures | **Household counts are UK-wide at both levels.** 575 + 57 + 18 = 650 constituencies; 318 + 11 here plus the 32 Scottish councils batch 4 already carries = 361 local authorities. The three census legs share one publisher-neutral record_set_spec_id per level (uk.local_geography.households.by_constituency.v1 and .by_local_authority.v1), so a single profile target spans them despite three census days - England and Wales 2021-03-21, Scotland 2022-03-20, Northern Ireland 2021-03-21 - each fact keyed to its own publisher's period. Two collisions avoided deliberately, both recorded in the package headers: - Scotland's local-authority household count is already a Chronicle fact, the all-occupied-households row of batch 4's UV404 council-area package. A second Scottish council-area package would collide on fact_key, so none is added. - CTSOP1.1's REGL, NATL and ENGWAL rows would collide with wave 2's voa-council-tax-bands-2025, which ports the same concept at region and country level from CTSOP2.0. Only the 318 LAUA rows are ported; MSOA and LSOA are below any declared target and UNMD has no GSS code. Every LAUA row guards geography == 'LAUA' so it cannot drift into the MSOA block. Suppression is not a value: 299 CTSOP1.1 cells carry '..' or '-' and emit no facts. Band I exists only in Wales, so 296 of the 318 rows do not have it; one band_a and two band_h cells are '-'. Rows are grouped by which measures they actually carry, giving four availability signatures (295 rows with bands A-H, 20 with A-I, 2 with A-G plus I, 1 with B-H). Scotland's export is name-keyed with no GSS codes, so each row's S14 code is authored from batch 1's NRS age package, which carries both, and guarded by the publisher name as the row header. All 57 map; verified against an independently rebuilt map. Implemented by Codex from a pre-verified plan; every fact re-derived here against its artifact - published cell counts equal fact counts, nothing dropped or invented, and no fact key collides with an existing package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Household counts move to ported-UK-wide across the three census legs, with the fact-key reason a second Scottish council-area package is absent. The council tax row becomes stock-ported with the levels and taxbase tables explicitly deferred: they back no profile target, uk-data's use of them is excluded-as-computed regardless, and their form-shaped artifacts need a column-by-column adjudication of their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One measured build of the whole tree, every pinned structure regenerated from it: the summary counts dict, by_source, by_period and by_entity as whole literals, the five scalars, and the per-geography pins checked against the build. 131,570 facts to 135,430, exactly the 3,860 the six batch-5 packages carry. 110 packages to 116; 105 source tables to 111. by_source moves on ons (59,953 to 60,846), nrs (5,532 to 5,589), nisra (481 to 510) and voa (120 to 3,001). Geography count is unchanged at 9,390 and every per-geography pin holds: batch 5 reports on constituencies and local authorities the bundle already had, adding measures rather than areas. Period count is unchanged at 117 for the same reason - the three census days and the VOA stock date were all already present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four publisher artifacts for the batch-6 council tax packages, fetched and published to R2 ahead of authoring: - MHCLG Council Tax levels England 2026-27, Table 10 local authority data (carries BOTH the council tax base and the average Band D per billing authority) - Welsh Government council tax levels April 2026 to March 2027 (Band D and its precept elements in Table 1, council tax income in Table 3) - ScotGov CTAS 2026 Band D council tax rates by year, 1996-97 to 2026-27 - ScotGov CTAXBASE 2025 Band D equivalents, before and after council tax reduction These close the gap the coverage audit found: policyengine-uk-data's LA council tax family reads a published Band D per authority and a published Welsh council tax income, and derives England's net council tax from the published taxbase. Wave 3 had the VOA band counts but none of those three, so a downstream consumer could not reconstruct what uk-data computes. Facts-only means the raw publisher values have to be here even when the target itself is computed later. gov.wales and gov.scot both 403 a plain fetch; these were retrieved with browser headers and a Referer, and the manifests record the publisher URLs rather than the local paths they were staged through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four packages, 5,008 facts, closing the gap the wave-3 coverage audit found. policyengine-uk-data's LA council tax family reads three publisher values that Chronicle lacked, and facts-only means they have to be here even though the target a consumer materialises from them is computed downstream: | package | facts | what it carries | |---|---|---| | mhclg-council-tax-levels-england-2026-27 | 2,368 | taxbase + 3 Band D measures x 296 billing authorities x 2 years | | scotgov-band-d-equivalents-2025 | 1,419 | Band D equivalent dwellings, before and after council tax reduction, 33 areas x 1996-2025 | | scotgov-band-d-council-tax-rates-2026-27 | 1,023 | average Band D, 33 areas x 1996-97 to 2026-27 | | welshgov-council-tax-levels-2026-27 | 198 | Band D and precept elements, budget requirement and council tax income x 22 authorities | England's net council tax stays excluded (computed) - it is taxbase x Band D - but both inputs are now published facts. Wales publishes its council tax income directly, so that is a fact rather than a computation. Three publisher quirks worth knowing, each of which would have been a silent drop: - **Footnote markers welded onto year headers.** ScotGov writes '2024-251' (footnote 1) and '2022a' (footnote a). A strict year regex rejects them, and my first pass silently lost all three columns - 33 facts from the rates table and 66 from the equivalents. The parser now takes the leading year for the period and keeps the publisher's raw label as the column guard. - **Scottish council names do not match ONS.** Four use '&' for 'and' and 'Shetland Islands' carries a double space. An explicit alias map does the code lookup; the row guards carry the publisher's exact string, double space and all. - **The after-CTR series starts in 2013**, not 1996, because council tax reduction replaced council tax benefit that April. 13 years against 30 is publisher behaviour, not missing data. Excluded and reported per package: MHCLG's six class-aggregate rows and its whole re-organising-authorities block; Wales's 'Wales average', 'Total counties', 'Total police', 'Total Wales' and four police authorities (precepting bodies with no LA GSS code); Wales's Change (£m) and Change (%) columns, which are arithmetic over ported columns; and the ScotGov footnote rows. Every fact re-derived against its artifact: published cell counts equal fact counts, nothing dropped or invented, every value matching after scaling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…PolicyEngine#134) The checklist kept wave-3 in its own section while the families were being ported. They are ported now, so there is one table for every uk-data calibration-target family, national and local, and nothing in uk-data is absent from it. Forty rows, every one carrying an explicit status. Two rows are re-based off "blocked", because wave 3 made them false: - **UK-wide tenure counts.** No single publisher table carries a UK-wide tenure count and composing one from the three census legs is PolicyEngine work, so the UK figures stay excluded (computed) - but the legs themselves are now ported at local-authority grain for all three jurisdictions, so a consumer can compose the total from facts instead of from uk-data's `~` approximations. - **Devolved constituency rent anchors.** uk-data hardcodes uncited country totals and allocates them by population share; both stay excluded (computed). But PIPR now carries an average private rent for Wales and Scotland and the census legs carry private-rented household counts, so the base facts exist. Only two blocked rows survive, both genuinely unobtainable: the £1,100/month mortgage payment, whose citation points at a bulletin that carries no mortgage figure, and the SPP Review NI-relief bases, whose PDF link-rotted with no Wayback copy. The status vocabulary now distinguishes three things the old wording blurred: a computation whose publisher inputs are all ported ("base facts ported"), an artifact that is pinned and parsed but simply does not print the quantity uk-data uses ("no publisher column"), and a source that cannot be obtained at all ("blocked"). The SPI amount and ONS housing-cost rows move from the blocked vocabulary to the second, which is what they actually are - the ODS and the MSOA workbook are both ported, they just have no such column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One measured build of the whole tree, every pinned structure regenerated from it: the summary counts dict, by_source, by_period and by_entity as whole literals, the five scalars, and the per-geography pins checked against the build. 135,430 facts to 140,438, exactly the 5,008 the four council tax packages carry. 116 packages to 120; 111 source tables to 115; 39 sources to 40 with welshgov. by_source gains mhclg 2,672 and welshgov 198 and moves scotgov 62 to 2,504. Period count 117 to 144. The 27 new periods are the ScotGov year series - Band D rates from 1996-97 and taxbase equivalents from 1996 - which reach much further back than anything else in the bundle. Geography count is unchanged at 9,390 and every per-geography pin holds: these packages report new measures on councils and districts the bundle already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial review (Codex): approve, no material findingsRan an adversarial Codex review over the branch diff against Verdict: approve. No defensible ship-blocking issue in the branch diff. What it exercisedThe approval is worth what it checked, so for the record:
Limits of this pass
Both are the profile-correction PR's work: #134's plan puts profile changes in the |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Now that all five batches are in, this reads well. The reviewable surface is ~540 lines against a million lines of generated packages, which is the right ratio for a wave PR, and the new lane is justified by a measured problem rather than a preference — a ~49k-row sheet whose used-range parse would cost millions of cell records per build is exactly the case that needs a full-row lane.
Two things the new lane gets right that its predecessors didn't: source_rows_from_xlsx_table raises on a missing declared sheet rather than returning [], and read_only=True, data_only=True is the correct pairing for the memory problem it exists to solve. Flattening datetimes to ISO text with the midnight rule for date-only is a good call — guards and criteria comparing against a stable string form is worth more than preserving the type.
Three things.
1. header_row isn't validated, and header_row=0 silently produces empty rows.
Both lanes do:
if row_number < header_row:
continue
if row_number == header_row:
header = [...]
continue
values = {... for index, column in enumerate(header or ())}with enumerate(..., start=1). With header_row: 0 — a plausible slip from zero-indexed thinking, and the kind of thing an authoring agent does — no line is ever < 0 or == 0, so every physical line falls through to the values branch with header still None. header or () then yields an empty column set, and the package emits one value-less SourceRow per line. No error, no zero-row signal, just a table of empty rows.
The other end is quieter still: header_row past the end of the sheet yields zero rows and no complaint, which is the return [] failure mode from the Stat-Xplore lane in a new place.
A if header_row < 1: raise ValueError(...) plus a refusal when the header line is never reached would close both, and the second is the one that matters — a package silently contributing nothing is invisible until someone diffs fact counts.
2. The workbook is never closed.
openpyxl.load_workbook(..., read_only=True) documents that read-only mode leaves resources open until close() is called — it's the one mode where openpyxl says so explicitly. This lane exists precisely because of memory pressure, so leaking the handle in the function whose purpose is to not hold the sheet in memory is worth fixing:
workbook = openpyxl.load_workbook(BytesIO(content), read_only=True, data_only=True)
try:
...
finally:
workbook.close()Worth doing even though BytesIO softens the consequence, because the next caller may not pass one.
3. The parser dispatch chain is now 26 branches, and this is the third PR to grow it.
I raised this on #141 when it was around ten; chronicle/source_package.py on main now carries 26 if self.parser == ... branches across build_source_rows and build_source_cells, and this PR adds more. Each new lane means a near-verbatim stanza in both methods, so "which parsers exist and what does each do to sheet_name" is now a question nobody can answer without reading both chains end to end.
A {parser_name: (rows_fn, cells_fn, sheet_default)} table would collapse both, make the set enumerable, and remove the class of bug where a lane is registered in one method and not the other. Not for this PR — but the cost is now compounding at roughly one lane per wave, and wave 3 isn't the last wave.
Smaller
- The
source_rows_from_delimited_textrestructure preserves the empty-file behaviour (returns[]) and keeps row numbers physical, which is the property the docstring promises. Good that the preamble-skipping is explained by naming SuperWEB2 rather than left as a magic parameter. - Three publishers for the constituency age family (ONS/NRS/NISRA covering 575/57/18 of the 650) with one publisher at LA level because Nomis
NM_31_1spans the whole UK — worth the sentence it got in the body; that asymmetry would otherwise look like an inconsistency. - The record-set-per-category shape with rows carrying geometry only, and the category's filters in
shared_filters/shared_constraints, is the right compact for a 650-row table. Documenting it in the harness doc rather than leaving it as precedent in one package is what makes it reusable for wave 4.
…cyEngine#134) Review fixes for the two full-row lanes: - Both lanes reject header_row < 1 up front. header_row: 0 previously fell through both loop branches, leaving the header unset so every physical line emitted a value-less SourceRow — no error, no zero-row signal. - Both lanes refuse when the content ends before the declared header line, naming the last line/row reached. A header_row past the end previously yielded zero rows silently, invisible until someone diffs fact counts. The delimited lane keeps its documented empty-content -> [] behaviour (now pinned by a test), so its refusal fires only when lines exist; the xlsx lane refuses even on an empty sheet, consistent with its refusal of a missing sheet. - The xlsx lane closes its workbook in a finally block. Read-only mode holds the source open until close() is called, and this lane exists for memory pressure. Five new tests pin the refusals in both lanes and the preserved empty-content promise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — both fixes are in b064989. 1. One deliberate asymmetry: the delimited lane keeps its documented Five tests pin the contract: 2. Workbook close. 3. Dispatch table. Agreed, and agreed it's follow-up scope — filed as #169 |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
b064989 closes both. Verified:
- Both lanes refuse
header_row < 1up front, and both refuse when content ends before the declared header line, with the message naming the last line reached and the header it never got to. That turns the silent-empty-table and silent-zero-rows cases into build failures, which is what I was after. try/finallyaround everything after the load, with the comment stating why read-only mode needs it.
The asymmetry is the right call and worth having written down: keeping the delimited lane's documented empty-content → [] behaviour (it predates this PR and now has a test pinning it) while the new xlsx lane refuses an empty declared sheet. A declared sheet with no rows really is the same authoring error as a missing sheet, and the new lane has no back-compat to preserve. Five tests covering both refusals in both lanes plus the preserved promise is the right coverage.
Thanks for filing #169 rather than letting the dispatch point evaporate — wave 4 before the chain grows again is exactly the right moment.
Nothing further — clear from my side.
Wave 3 of the UK migration: source packages for the local-geography surface.
Complete. Draft — the profile-correction PR follows separately.
Part of #134.
What is here
Twenty-four packages, 95,571 facts, all at the publisher's own geography.
ons-pcon24-population-by-age-2024,nrs-pcon24-population-by-age-2024,nisra-pcon24-population-by-age-2024,ons-lad-population-by-age-2024hmrc-spi-income-by-area-2023-24dwp-uc-households-by-constituency-may-2025,dwp-uc-households-by-local-authority-may-2025,dwp-uc-households-by-constituency-children-may-2025ons-small-area-income-msoa-fye2023ons-census2021-ts054-tenure-lad,nrs-census2022-uv404-tenure-council-area,nisra-census2021-tenure-lgdons-subnational-dwellings-by-tenure-2024ons-pipr-rents-by-area-june-2026ons-census2021-ts041-households-pcon24,ons-census2021-ts041-households-lad,nrs-census2022-households-ukpc24,nisra-census2021-households-pcon24,nisra-census2021-households-lgdvoa-council-tax-stock-by-lad-2025mhclg-council-tax-levels-england-2026-27,scotgov-band-d-equivalents-2025,scotgov-band-d-council-tax-rates-2026-27,welshgov-council-tax-levels-2026-27The constituency families land on the same geography: 650 Westminster seats on
2024 boundaries. The age family needs three publishers to cover them (ONS via
Nomis for the 575 England and Wales seats, NRS for 57, NISRA for 18); the
local-authority level needs one, because Nomis
NM_31_1spans the whole UKincluding the 11 Northern Ireland districts.
The batch-4 families are local-authority and below, and each lands at whatever
grain its publisher actually releases — 361 UK local authorities for tenure
across three census legs, 7,264 MSOAs for small-area income, 295 English
districts for the dwelling series, and 348 areas for rents. Nothing is
re-aggregated to make the grains match; that is downstream build work.
Row-count mechanics, as #134 asked
The compact shape for a whole table, confirmed on a 650-row one and now written
into
docs/agent-source-package-harness.md:row_number, a row-header guard on the codecolumn, the per-row geography overrides, and a guard proving the row belongs
to the category its record set claims;
shared_filters/shared_constraints, declared once and merged into every fact.That is ~11–16 YAML lines per fact against ~26 for the per-geography
orientation, which is what produced the repo's two 200k-line packages. Where the
layout is ours to choose (Nomis, Stat-Xplore, NISRA PxStat) the artifact is
long format; where the publisher fixes it (the SPI ODS, the NRS workbook) we
parse their grid as published and never re-shape it.
Ten-year bands are a declared sum, never arithmetic here
The profile declares eight ten-year age bands. ONS publishes quinary groupings
and nothing coarser, so banding at download stops at five years. The ten-year
step is carried by a publisher-neutral
record_set_spec_id(
uk.local_geography.population.age_0_10.v1and siblings) that the ONS, NRS andNISRA packages share: the profile's sum-only operation then adds exactly the
publisher facts that make up the band. The grain differs by publisher — quinary
in England and Wales, single year in Scotland, five-year bands in Northern
Ireland — and the spec-id grouping absorbs that without any package restating
another's classification.
Tenure is UK-wide, and the household and dwelling series stay apart
All three census legs are ported, so household tenure covers the UK: TS054 for
318 England and Wales districts, NRS UV404 for 32 Scottish councils, NISRA
HH_TENURE for 11 Northern Ireland districts at its full eleven-category
classification rather than the four-category roll-up the profile could not use.
The legs classify differently — Scotland has one social-rented category where
England and Wales has two, Northern Ireland splits private renting five ways,
and Scotland's "Shared Equity (LIFT or Help-to-Buy)" has no counterpart at all.
Concepts that genuinely match share a publisher-neutral
record_set_spec_idbucket so one target sums its bucket across legs; unmatched categories keep
their own bucket rather than being forced into one.
SPREE is the same tenure language over a different entity, so it carries
entity: dwellingand its ownuk.local_geography.dwellings.*spec ids. Atarget pinned to entity household can never tie-break against it — the
separation requirement from the plan, enforced structurally rather than by
convention.
The Scottish census table does not add up, by design
NRS applies statistical disclosure control cell by cell. In 26 of 32 councils
the eight detail categories differ from the published all-households total, by
−6 to +10 households (median 3); the publisher's own roll-ups differ from their
own details in 17 councils. Scotland-wide the gap is 8 households in 2,509,275.
Every value is carried exactly as published and nothing is reconciled toward
anything else. The England and Wales leg, by contrast, adds up in all 318
districts. Each package header states which behaviour its leg has, because a
consumer that assumes additivity across the UK would be wrong in Scotland.
Household counts are UK-wide at both levels
575 + 57 + 18 = 650 constituencies; 318 + 11 in batch 5 plus the 32 Scottish
councils batch 4 already carries = 361 local authorities. Three census legs share
one publisher-neutral
record_set_spec_idper level, so a single profile targetspans them despite three different census days — England and Wales 2021-03-21,
Scotland 2022-03-20, Northern Ireland 2021-03-21 — each fact keyed to its own
publisher's period.
Scotland's local-authority household count is deliberately not added again:
it is already the all-occupied-households row of batch 4's UV404 council-area
package, and a second package would collide on
fact_key. The same reasoningkeeps CTSOP1.1's region and country rows out of the council tax package, where
wave 2's
voa-council-tax-bands-2025already ports that concept from CTSOP2.0.Every publisher input uk-data reads is here
Batch 6 exists because a coverage audit against
policyengine-uk-data/targets/sources/la_council_tax.pyfound three publishervalues wave 3 had missed: the average Band D per authority, the council taxbase,
and the Welsh council tax income. Facts-only means those have to be in Chronicle
even when the target a consumer materialises from them is computed later —
otherwise the computation cannot be rebuilt from facts.
They are ported now, for all three jurisdictions. England's net council tax stays
excluded (computed) because it is taxbase × Band D, but both inputs are facts.
Wales publishes its council tax income directly, so that is a fact rather than a
derivation.
Three publisher quirks, each of which would have been a silent drop:
'2024-251'and'2022a'. A strict year regex rejects them; the first pass lost 99 facts thatway. The parser now takes the leading year for the period and keeps the raw
label as the column guard.
&forand, andShetland Islandscarries a double space. An alias map does the code lookup;the row guards carry the publisher's exact string.
reduction replaced council tax benefit that April. 13 years against 30 is
publisher behaviour, not missing data.
Findings that are the profile's decision, not a sourcing gap
income type, and an amount only for total tax.
hmrc.employment_income.amountand
hmrc.self_employment_income.amounthave no publisher fact;policyengine-uk-data forms them as count × mean, excluded (computed).
{ons, population}), so as theprofile stands each would sum the same fact set and every band would resolve
to total population. They need to select the spec ids.
ons.equiv_housing_costshas no publisher fact either; uk-data forms one asBHC minus AHC, excluded (computed).
so the three
ons_incometargets move togeography_level: msoa.All of these are for the profile-correction PR, together with the
householdstarget batch 5 will back and the
entitypin the tenure targets need.Enumerated exclusions
Northern Ireland separately.
so it carries no facts.
[Not available]is a statement that nothing waspublished; rows are grouped by which measures they carry, so a suppressed cell
shows as a missing fact rather than a zero.
count with no GSS code, recorded rather than forced into a geography.
loss.pynever reads; not a target input.(Scotland's "Owned: Total" and "Private rented: Total"; Nomis's Owned / Social
rented / Private rented aggregates) — porting both layers would double-count
inside the tenure buckets. Verified to be sums of ported details, so the
exclusion loses nothing.
[x]at June 2026because the NI series lags; the December-2025 NI level is already ported in the
wave-2 bulletin package. City of London and Isles of Scilly are absent from the
publisher's rent table entirely.
re-presentations of ported estimates that no declared target needs.
citation; blocked until a publisher table surfaces.
age scaling, and count × mean amounts — computed, populace-side.
Lane changes
xlsx_table_full_rows, a single-sheet full-row lane. The PIPR monthlyworkbook is one ~49,000-row sheet whose used-range cell parse is ~2M cell
records per build — the operational blocker recorded on UK wave 2: housing base facts — PIPR bulletin rents and EHS weekly housing costs (#133) #159. The whole sheet
is preserved as source rows and
selected_rowsrestrict cell emission, so the348 rent facts cost 13,960 cells with every row still queryable.
header_rowon both full-row lanes, for export tools that write ametadata preamble above the table (NRS's SuperWEB2 export puts ten lines
there). Authors beware: cell emission renumbers preserved rows virtually, so
a package whose artifact has a preamble must cite the virtual row, not the
physical line. They coincide only when the header is line 1 — the first UV404
attempt cited physical lines and a tenure guard caught it, ten rows off.
ALLOWED_GEOGRAPHY_LEVELS:parliamentary_constituency→constituency. Theold name was unused; the profile and populace's runtime both say
constituency. Renamed rather than adding a synonym.msoaadded for thesmall-area income grain.
_source_row_age_rangereads three more publisher wordings — Nomis's"Aged 85+" and "Aged under 1 year", and NISRA's "Five year age bands" column —
so those bands evidence the same numeric bounds every other band carries.
source_cells_from_xlsxtakes an optionalsheetsallowlist. The NRSworkbook is 963k cells across sixteen geography worksheets of which this
package reads one; naming it keeps the per-build cost proportionate. Tested
both ways, including that a renamed sheet fails loudly.
Verification
Per package:
validate-package, thenpublish-raw(facts needraw_r2_uri),then
build-suite— all green, lineage coverage 1.0, zero agent-acceptanceerrors.
Every fact was re-derived independently against its artifact: fact count equals
published-cell count, nothing dropped, nothing invented, every value matching
after publisher scaling. Additivity anchors hold where the publisher gives one —
the age bands sum to each area's published total, and NISRA's own Northern
Ireland total (1,927,855) equals the sum of its 18 seats to the person.
Two cross-checks recorded rather than corrected:
between the totals cube and the sum of the children cube, median 4 households
and at most 15 (GB 6,577,205 against 6,576,846). DWP adjusts cells at source.
N09000011 Ards and North Down, which uk-data's committed CSV drops.Scotland's census export carries council names only, so each row's S12 code is
authored from the publisher name — cross-checked 32/32 against the batch-1
local-authority age package, which carries both — and guarded by that exact name
as the row header.
The bundle expectation surface was regenerated whole from one measured build.
The checklist is now one table
docs/pe-uk-source-checklist.mdcarried wave-3 in its own section while thefamilies were being ported. They are ported, so there is one inventory for every
uk-data calibration-target family, national and local — forty rows, every one
with an explicit status, and nothing in uk-data absent from it.
Two rows came off blocked, because wave 3 made the blocking false: UK-wide
tenure counts and the devolved constituency rent anchors both now have every
publisher input ported, so they are excluded (computed) with base facts rather
than sourceless. Only two blocked rows survive, both genuinely unobtainable — the
£1,100/month mortgage payment whose citation points at a bulletin carrying no
mortgage figure, and the SPP Review PDF that link-rotted with no Wayback copy.
The status vocabulary now separates three things the old wording blurred: a
computation whose publisher inputs are all ported, an artifact that is pinned and
parsed but does not print the quantity uk-data uses (the SPI amounts and the ONS
housing costs, previously mislabelled "blocked"), and a source that cannot be
obtained at all.
Next
The profile-correction PR, which now has a settled list: the eight age selectors,
the three targets with no publisher fact, the
ons_incomemove to MSOA, theentitypin on tenure, and a newhouseholdstarget these five packages back.