Skip to content

UKMOD statistics adapter (mode 1): Country Report validation tables - #47

Merged
MaxGhenis merged 3 commits into
mainfrom
uk/ukmod-stats-adapter
Aug 16, 2026
Merged

UKMOD statistics adapter (mode 1): Country Report validation tables#47
MaxGhenis merged 3 commits into
mainfrom
uk/ukmod-stats-adapter

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

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 ISER
  • sources/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 at data/externals/ukmod-stats.json
  • source.json + 7 annotations; data/lanes.jsonukmod-stats: registered → ingested

Coverage

  • Tables 4.5/4.6 — simulated caseloads (thousands; per-row unit: families / children / households / individuals) and expenditure (£m) for 30 instruments × policy years 2023–2030: UC, child benefit (both families and children-covered), pension credit, HB, CTR, legacy benefits, Scottish benefits, benefit caps, NICs (employee/self-employed/employer), and income tax with the full devolved band breakdowns (rUK savers/basic/higher/additional; Scottish starter→top incl. the advanced rate from 2026)
  • Tables 4.7/4.8 — Gini, mean/median and quintile medians/shares of equivalised BHC disposable income, and poverty rates below 50/60/70% of median by age group and gender

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_cited rows 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

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>
r and others added 2 commits August 14, 2026 13:50
…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>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Correction: 40 of Table 4.7's values were wrong

A review of this PR found a real data-corruption defect, now fixed in d6a1a5a/1f2e0f4. Recording it plainly.

What was wrong. parse_rows matched each row's anchor as a substring of whitespace-flattened page text. Table 4.7's quintile rows are anchored on bare digits ("2", "3", "4"), and those matched digits inside preceding numbers — the "2" anchor hit the trailing 2 of 1352, so quintile 2's window started two tokens early and swallowed the row label. The over-long token run was then silently truncated, because the length guard only checked the too-few direction.

cell report was emitted
q2 median, ukmod 2024 2,333 2.0 ← the row label itself
q2 median, ukmod 2023 2,238 1,527 (q1's HBAI value)
q3 median, ukmod 2023 2,869 8.0
q4 median, ukmod 2023 3,779 91.0
q3 share, all years 0.17 0.13

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 2 mid-line is a data value, not a row label). Values are read from the anchor's own line and continue onto later 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 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:

  • structural_invariants() — per variant and year: quintile shares sum to 1, quintile medians increase, the middle quintile's median equals the population median, 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 and never touches sources/, so the adapter's own checks were invisible to it). 5 of the 7 new tests fail on the pre-fix output.
  • pypdf is now imported lazily so the invariants are importable, and testable, without it.

Two smaller items from the same review, also fixed: expenditure rows carried the caseload registry's counting unit (families on a £ value) and now carry gbp_nominal; the 325 absent (-/na) cells are tallied and reported rather than silently skipped.

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.

vahid-ahmadi pushed a commit that referenced this pull request Aug 14, 2026
…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>
@MaxGhenis
MaxGhenis merged commit b21d17c into main Aug 16, 2026
2 checks passed
@MaxGhenis
MaxGhenis deleted the uk/ukmod-stats-adapter branch August 16, 2026 16:08
vahid-ahmadi pushed a commit that referenced this pull request Aug 19, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vahid-ahmadi pushed a commit that referenced this pull request Aug 19, 2026
…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>
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.

UKMOD published statistics adapter (mode 1)

2 participants