Skip to content

OBR welfare baseline adapter (mode 1, baseline_moment): EFO March 2026 - #46

Merged
MaxGhenis merged 2 commits into
mainfrom
uk/obr-welfare-adapter
Aug 16, 2026
Merged

OBR welfare baseline adapter (mode 1, baseline_moment): EFO March 2026#46
MaxGhenis merged 2 commits into
mainfrom
uk/obr-welfare-adapter

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Closes #37. Fourth UK mode-1 adapter (parent #3), and the first UK entry in the baseline_moment claim class from #29 — current-law statistics from an external modeler, captured with the same honesty machinery as reform scores.

What's here

  • sources/obr-welfare/raw/ — the March 2026 EFO detailed forecast tables: expenditure workbook, as fetched from obr.uk (published 2026-03-03)
  • sources/obr-welfare/adapter.py — stdlib-only xlsx reader (zipfile + sharedStrings XML) → 259 tidy rows at data/externals/obr-welfare.json
  • source.json with claim_class: baseline_moment and an explicit baseline_key (per Baseline as a first-class attribute of every score #13) + 7 annotations
  • data/lanes.jsonobr-welfare: registered → ingested

Coverage

Table 4.9 — post-measures breakdown of welfare spending: 31 benefit lines (UC, state pension, DLA/PIP, incapacity benefits, attendance allowance, pension credit, carer's allowance, HB, child benefit, WFP, …) × 2024-25 outturn + forecast years 2025-26 → 2030-31, at the workbook's full float precision. The welfare-cap/outside-cap split is carried as the row variant — UC legitimately appears in both (the cap excludes the jobseeking-conditionality portion), so a PE UC aggregate compares against the sum of the variants, which the annotations spell out. Geography follows administration: DWP lines are GB, NI mirror social security is NI, HMRC-administered lines (child benefit, tax credits, statutory payments) and totals are UK.

Tables 4.10 (changes since November 2025) and 4.11 (health- and disability-related welfare — the right counterpart for UC-health-inclusive concepts) are vendored in the same workbook for follow-up emission.

Validation

Three magnitude pins (state pension £146.2bn, UC-in-cap £66.4bn, total welfare £332.9bn in 2025-26) plus the cap + outside = total identity checked for all seven years at £1m tolerance. * (< £0.1bn) cells → suppressed (42).

Honesty boundary

The lead annotation is the consumed_as_target rule: pe-uk calibrates spending totals to official statistics that feed the same OBR round — per-benefit classification is required before any of this scores as held-out validation, with the 2024-25 outturn column highest-risk (mirror of the cbo-baseline lane's 317 pre-verified rows). Baseline discipline per #13/#21: post-measures March 2026 is a distinct baseline_key; a newer EFO is a different baseline, never a revision. The obr_measures campaign family (10 staged PE results) attaches via #33.

🤖 Generated with Claude Code

…6 Table 4.9

Fourth UK mode-1 adapter (#37, parent #3), in the baseline_moment claim
class from #29: current-law statistics from an external modeler. Parses
the March 2026 EFO detailed expenditure workbook (stdlib xlsx reader)
into 259 tidy rows at data/externals/obr-welfare.json — Table 4.9's
post-measures welfare spending by benefit: 31 program lines x 2024-25
outturn + forecast to 2030-31, at full float precision (£bn -> raw GBP).

The welfare-cap split is the variant (universal credit and the
aggregates legitimately appear in both sections; a PE UC aggregate is
the SUM of the two variants). Geography follows administration: DWP
lines GB, NI mirror social security NI, HMRC-administered lines and
totals UK. Validation: three published-magnitude pins (state pension
GBP146.2bn, UC-in-cap GBP66.4bn, total welfare GBP332.9bn in 2025-26) plus the
cap+outside=total identity for all seven years. '*' (<GBP0.1bn) cells
carried as suppressed.

source.json records the baseline_key (post-measures March 2026 EFO;
each round a distinct baseline, never a revision) per #13/#21.
Annotations pin the consumed_as_target boundary (pe-uk calibration vs
DWP/HMRC outturns, mirroring the cbo-baseline lane's 317 pre-verified
rows), outturn-vs-forecast claim types, the UC-health exclusion in the
incapacity line, and the accrual/AME spend concept. Lane obr-welfare:
registered -> ingested; the obr_measures campaign family attaches via
#33.

Closes #37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Table 4.9 is nested, but the adapter skipped the 'of which:' markers and
emitted all 31 programs as siblings. 'DWP social security' is the SUM of
the eleven rows under its marker, and 'Other DWP in welfare cap' is a
further subtotal of ten inside it — so any consumer aggregating the
non-total rows double-counts. For 2025-26 the in-cap rows sum to
£319.85bn against a published cap total of £169.13bn, 1.89x.

Every row now carries aggregate_level (component | subtotal | total) and
parent, read from the workbook itself: an 'of which:' marker at indent L
declares the preceding level-L row the parent of the following level-L+1
rows, with levels taken from each label cell's style indent. A row
indented without a governing marker is a hard error rather than a guess.
Strictly additive — all pre-existing fields, values and key order are
byte-identical, still 259 rows.

Replace the insensitive structural check. cap + outside = total compares
three published figures, so it holds even if a component is dropped or
renamed. Each aggregate is now reconciled against the rows naming it as
parent, for all seven years:

  - exact to PARENT_TOL = £1 where every component is published. Values
    are full float64 £bn scaled by 1e9, so ≤11 terms of order 1e2 carry
    ~£1e-4 of representation error; in practice both DWP social security
    sections and the in-cap section total reconcile to exactly 0.0. £1
    is ~8 orders below the smallest published component (£0.093bn).
  - bounded by n_suppressed × £0.1bn where components are suppressed,
    since '*' only asserts |x| < £0.1bn. Two parents need this: 'Other
    DWP in welfare cap' (5 of 10 suppressed) and the outside-cap section
    total (1, negative in the early years).

Also fix the silent 'else: continue' at row classification: an unmapped
label carrying data now raises, and non-data prose is tallied and
printed rather than vanishing. 59 checks pass, 0 fail.

tests/test_obr_adapter.py pins the invariants against the committed
JSON: components sum to their parent per year, every component has a
resolvable parent, leaves-only sums equal the published section total
(the assertion that catches a flat emission), and the 1.89x failure mode
itself. Verified non-vacuous — collapsing the subtotals back to
components fails 6 of the 8 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

The of which: hierarchy was being discarded — pushed a fix

I flattened Table 4.9 when it isn't flat, and the validation I wrote couldn't see it.

The failure. SKIP_LABELS = {"of which:"} threw away the only thing marking the nesting, so all 31 programs came out as siblings with no level marker. But DWP social security (£137.273bn in-cap, 2025-26) is the sum of the eleven rows under its marker, and Other DWP in welfare cap is a further subtotal of ten inside that. Sum the in-cap non-total rows for 2025-26 and you get £319.85bn against the published cap total of £169.13bn — 1.89x. Any consumer aggregating these rows double-counts, and a PE counterpart mapping that treats dwp_social_security as a program rather than an aggregate does so silently.

Why the existing check didn't catch it. cap + outside = total compares three published figures to each other. It is mathematically blind to the whole class of defect here — drop a component, rename one, misparent one, count one twice, and it still passes. It was the only structural check in the adapter, and it was never going to fire.

The fix. Every row now carries aggregate_level (component | subtotal | total) and parent, read from the workbook rather than assumed: an of which: marker at indent level L declares the preceding level-L row to be the parent of the following level-L+1 rows, with the levels coming from each label cell's style indent. A row indented without a governing marker raises rather than getting a guessed parent. Strictly additive — every pre-existing field, value and key order is byte-identical, still 259 rows (31 components + 3 subtotals + 3 totals = 37 label rows x 7 years).

The new invariant — for each of the seven years, every aggregate equals the sum of the rows naming it as parent:

  • Exact, tolerance £1, where all components are published. Values are full float64 £bn scaled by 1e9, so summing <=11 terms of order 1e2 carries only ~£1e-4 of representation error; in practice both DWP social security sections and the in-cap section total reconcile to exactly 0.0. £1 sits ~4 orders above float noise and ~8 orders below the smallest published component (£0.093bn), so it can't mask a dropped row.
  • Bounded by n_suppressed x £0.1bn where components are suppressed — worth being explicit that an exact check is impossible there, since * only asserts |x| < £0.1bn. Two parents need this: Other DWP in welfare cap (5 of its 10 components suppressed; residual £63-84m) and the outside-cap section total (1 suppressed component, which is negative in 2024-25 to 2026-27 — the sum of published children exceeds the published parent).

Also fixed the else: continue at row classification: an unmapped label carrying data now raises with the label, and non-data prose (title, footnotes) is tallied and printed instead of vanishing. Confirmed both bite — deleting a PROGRAMS entry raises, and blanking the of which: markers raises on the first indented row instead of silently producing a flat table.

Adapter run: 259 rows, 59 checks OK, 0 FAIL, 42 suppressed cells (both unchanged).

Regression tests in tests/test_obr_adapter.py (8, skipped if the JSON is absent) load the committed data/externals/obr-welfare.json and assert components sum to their parent per year, every component has a resolvable parent, leaves-only sums equal the published section total — that one is the assertion that would have caught this — plus the pinned row count and the 1.89x failure mode itself. Verified non-vacuous: collapsing the subtotals back to component fails 6 of the 8, reporting exactly 319,851,354,404 vs 169,131,197,990.

Full suite: 135 passed, 4 skipped. ruff format --check clean. Added a nested-hierarchy-double-count annotation (severity construction) so the hazard travels with the data.

Follow-up, not done here: #48 (scorecard_db/ingest_uk_externals.py, different branch) carries the flat structure into the claim DB and will want the same level marker in conditions — otherwise the double-count just moves downstream into the claims. Left that for #48 rather than reaching across branches.

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.

OBR welfare trends / EFO baseline adapter (mode 1, baseline_moment class)

2 participants