UKMOD statistics adapter (mode 1): Country Report validation tables - #47
Conversation
Fifth UK mode-1 adapter (#38, parent #3) — the peer-model lane. Parses the UKMOD Country Report 2023-2030 (CeMPA WP 8/26, version B2026.01) validation tables into 1,131 tidy rows at data/externals/ukmod-stats.json: - Tables 4.5/4.6: simulated caseloads (thousands, unit per row: families/(c)hildren/(h)ouseholds/(i)ndividuals) and expenditure (GBPm) for 30 instruments x policy years 2023-2030, including the devolved income tax band breakdowns (Scottish advanced rate appears 2026), alongside the official estimates the report cites - Tables 4.7/4.8: income distribution (Gini, mean/median, quintile medians/shares) and BHC poverty rates below 50/60/70% of median by age group and gender Claim provenance is the variant: 'ukmod' (simulated), 'official_as_cited' (the report's transcription of DWP/HMRC/OBR/SFC estimates - annotated to resolve to primary sources, several of which are now sibling lanes), 'input_data', 'hbai'. Missing cells are absent claims, skipped rather than suppressed. Parsing is pypdf text extraction (declared dependency; the one UK adapter that needs more than stdlib because the source is a PDF) with an ordered row registry anchored on UKMOD variable names; anchor misses or token shortfalls fail the run. 12 report-pinned checks pass. Annotations record the model-vs-model decomposition contract (shared FRS base, different weights/uprating, take-up frozen at 2021 outside Scotland/pension-age per the report's own health warning), the BHC/modified-OECD triangle with the hbai-poverty lane, policy-year vs financial-year timing, and the open-source upstream path (unique among UK lanes). Lane ukmod-stats: registered -> ingested. Closes #38 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…riants
A code review caught a real data-corruption defect in this adapter. The
parser matched row anchors as SUBSTRINGS of whitespace-flattened text,
so the bare digit anchors of Table 4.7's quintile rows ('2', '3', '4')
matched digits INSIDE preceding numbers — the '2' anchor hit the
trailing 2 of '1352', shifting five rows onto their neighbours' values.
40 of Table 4.7's 130 values were wrong, including quintile 2's 2024
median stored as GBP2/month (the row label itself) instead of GBP2,333, and
quintile 3's 2023 median as 8.0 instead of 2,869. All 12 spot-checks
printed OK throughout, because none of them touched those rows.
Fixes:
- parse_rows now matches WHOLE TOKENS, never substrings, and requires a
numeric anchor to sit at the start of its line (a bare '2' mid-line is
a data value, not a row label). Values are read from the anchor's own
line and continue onto following lines only while those lines hold
nothing but values — which keeps Table 4.6's wrapped benefit-cap rows
working without letting a row run on into the next labelled row.
- Tables 4.5/4.6/4.8 are byte-identical after the rewrite (independently
machine-diffed against the PDF at 0 mismatches before the fix); only
the 40 corrupt Table 4.7 cells changed, and each now matches the
report exactly.
- Expenditure rows carried the caseload registry's counting unit
('families' on a GBP value); money rows now carry gbp_nominal.
- The 325 absent (unpublished '-'/'na') cells are now tallied and
reported instead of silently skipped.
New defences, because spot-checks structurally cannot see a shifted row:
- structural_invariants(): per variant and year, quintile shares sum to
1, quintile medians increase, the middle quintile's median equals the
population median, and median <= mean. 40 invariants, all pass; they
fire 27 times on the pre-fix data.
- tests/test_ukmod_adapter.py puts those identities plus hand-read PDF
ground truth in CI's path (CI runs pytest, never sources/). 5 of the 7
new tests fail on the pre-fix output.
- pypdf is now imported lazily so the invariants are importable, and
testable in CI, without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correction: 40 of Table 4.7's values were wrongA review of this PR found a real data-corruption defect, now fixed in What was wrong.
40 of Table 4.7's 130 values. All 12 spot-checks printed OK the whole time, because none of them touched those rows — that is the real lesson here, not the regex. The fix. Anchors now match whole tokens, never substrings, and a numeric anchor must sit at the start of its line (a bare Tables 4.5, 4.6 and 4.8 are byte-identical after the rewrite (they had been machine-diffed against the PDF at 0 mismatches, and stay that way); only the 40 corrupt cells changed, and each now matches the report exactly, including the input-data and HBAI reference columns. Defences added, since hand-picked spot-checks structurally cannot see a shifted row:
Two smaller items from the same review, also fixed: expenditure rows carried the caseload registry's counting unit ( Downstream: PR #48 ingests this file, and 32 of the wrong values had reached the claim DB through it. No change to #48 is needed — it re-reads this output — but it should be re-run after this merges. |
…istry Three defects from code review of this PR. 1. The ready-reckoner policy slug ignored the tax head. HMRC prints "Change standard rate by 1 percentage point" under BOTH the VAT and the Insurance Premium Tax sections, so both slugged to trr_change_standard_rate_by_1_percentage_point — six claims sharing one reform_key across worlds 14x apart in magnitude (GBP9.2bn vs GBP640m in 2027-28). finish() could not catch it: the claim ids differ by program, but the ReformRef is the executable descriptor the PE side computes against, and scorecard_db/README.md requires identical worlds to share a key and distinct worlds not to. The slug now carries the tax head, and a test plus a DB-level assertion (no reform_key spanning two programs) pin it. 2. calibration_relationship was decided inline per metric, bypassing scorecard_db/relationships.py — the module whose own docstring calls itself the canonical home, assigned "not defaulted". Added uk_relationship() there, keyed by (source, metric) because the UK families are publication-shaped, and it RAISES on an unassigned pair rather than defaulting to held_out. 3. Within that, entitled-non-recipient counts and unclaimed amounts were held_out while the take-up rate they are algebraically derived from (takeup = R/(R+ENR)) was consumed_as_target — an apparent held-out win that the 2026-08-02 doctrine's "nor anything derived from such" already covers. The DWP take-up family is now consumed as a whole, including the recipient counts, which are the admin caseload PE UK calibrates to. Where the per-parameter audit of pe-uk's provenance is still owed the conservative direction is taken — consumed, never held_out, since mislabelling a tautology as a validation win is the failure the doctrine exists to prevent. Also: POVERTY_COUNT joins PERMANENT_HOLDOUT_METRICS. A poverty count is a poverty rate with the denominator multiplied back in — the same survey-derived quantity — and this ingest is the first population to carry counts (6,528 HBAI rows). Without it a future ingest could mark them consumed and the doctrine guard would not fire. Re-verified end to end against the fixed adapter outputs (#43-#47): 16,924 claims (dwp_takeup 1,638 / dwp_hbai 13,056 — +168 from #44's recovered rows / uk_hmrc 1,213 / obr 259 / ukmod 758), zero reform_key collisions, zero poverty claims marked consumed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion-key registry, true claim count - stage_obr carries aggregate_level (component/subtotal/total) and parent on every claim so consumers cannot double-count roll-up siblings when summing OBR benefit_cost by FY. - _load() now enforces the harvest.py require_fields contract with a per-file known-field registry: an unhandled adapter column raises instead of dropping silently (how the OBR hierarchy columns slipped through). - component, aggregate_level, parent registered in STANDARD_CONDITIONS. - Headline count reconciled: the ingest produces 16,924 claims (dwp 1638 / hbai 13056 / hmrc 1213 / obr 259 / ukmod 758), not the 16,756 previously stated. - Merged main so the five adapter sources (#43-#47) live under this branch and the integration tests run against regenerated outputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #38. Fifth UK mode-1 adapter (parent #3) — the peer-model lane. UKMOD is the closest peer microsimulation (FRS-based, EUROMOD platform), so these are the highest-signal model-vs-model rows the UK side can produce.
What's here
sources/ukmod-stats/raw/— the UKMOD Country Report 2023–2030 PDF (CeMPA WP 8/26, March 2026, version B2026.01) as fetched from ISERsources/ukmod-stats/adapter.py— pypdf-based parser (the one UK adapter needing a non-stdlib dependency, because the source is a PDF; declared and guarded) → 1,131 tidy rows atdata/externals/ukmod-stats.jsonsource.json+ 7 annotations;data/lanes.json—ukmod-stats:registered → ingestedCoverage
Claim provenance is carried as the variant:
ukmod(simulated),official_as_cited(the report's own transcription of DWP/HMRC/OBR/SFC estimates),input_data,hbai. Missing cells are absent claims — skipped, not suppressed.Parsing + validation
Text extraction with an ordered row registry anchored on UKMOD variable names (
bsauc_s,tin00_s, …) and band labels; every row must consume exactly its expected token count, and any anchor miss fails the run. 12 checks pinned to the report's printed values pass (UC 6.941M simulated vs 6.493M official caseload 2025, £79.5bn official UC expenditure, Gini 0.32 UKMOD vs 0.33 HBAI, child poverty 19% UKMOD vs 20% HBAI, Scottish advanced-rate payers, …).Honesty boundary
The annotations pin the decomposition contract: UKMOD shares PE UK's survey base but differs in weighting (HBAI-adjusted FRS 2023/24), uprating, and take-up corrections frozen at 2021 outside Scotland/pension-age — the report's own health warning — so PE-vs-UKMOD wedges route through data/uprating/take-up channels before indicting either engine.
official_as_citedrows must resolve to primary publications before scoring (several are now sibling lanes: HMRC #45, OBR #46), with transcription mismatches becoming external-issue seeds. The 4.7/4.8 tables use the same BHC/modified-OECD construction as the hbai-poverty lane (#44), making the PE/UKMOD/HBAI triangle well-posed. Case-level adjudication stays with the mode-3 JRC-connector lane (#41). UKMOD is open source, so confirmed external-model issues here have an upstream path — unique among UK lanes.🤖 Generated with Claude Code