diff --git a/app/public/data/lanes.json b/app/public/data/lanes.json index d2ea06a..4a3eee5 100644 --- a/app/public/data/lanes.json +++ b/app/public/data/lanes.json @@ -346,6 +346,17 @@ "running": false, "updated": "2026-08-19", "note": "7 claims" + }, + { + "id": "obr-policy-effects", + "source": "OBR", + "area": "published economic effects of policy", + "mode": 2, + "country": "UK", + "stage": "ingested", + "running": false, + "updated": "2026-08-20", + "note": "266 claims" } ] } diff --git a/data/lanes.json b/data/lanes.json index d2ea06a..4a3eee5 100644 --- a/data/lanes.json +++ b/data/lanes.json @@ -346,6 +346,17 @@ "running": false, "updated": "2026-08-19", "note": "7 claims" + }, + { + "id": "obr-policy-effects", + "source": "OBR", + "area": "published economic effects of policy", + "mode": 2, + "country": "UK", + "stage": "ingested", + "running": false, + "updated": "2026-08-20", + "note": "266 claims" } ] } diff --git a/scorecard_db/README.md b/scorecard_db/README.md index 91a4cab..af1db75 100644 --- a/scorecard_db/README.md +++ b/scorecard_db/README.md @@ -155,3 +155,85 @@ this ingest mints is marked publication.registry = "populace_reform_validation" — that marker is the idempotency contract (re-ingest deletes and recreates exactly these claims, never the harvest claims it attaches results to). + +## 2026-08-20 population: OBR published policy effects (UK) + +Fourth UK population and the external half of the Macro entry point +(issue #55): 266 claims from OBR's own published estimates of what +fiscal policy does to the ECONOMY, harvested by +`sources/obr-policy-effects/adapter.py` and ingested by +`ingest_obr_policy_effects` (chain position: after `uk_deductions`, +before the campaign attaches). + +```bash +PYTHONPATH=. python -m scorecard_db.ingest_obr_policy_effects data/scorecard.db +``` + +Four families on four new metrics — `gdp_level_effect` (151), +`cpi_inflation_effect` (36), `supply_side_impact` (19) and +`decisions_effect_on_borrowing` (60). The first and third are +deliberately distinct metrics: a package's effect on the actual-GDP path +is not one measure's supply-side scoring, and +`decisions_effect_on_borrowing` (PSNB) is likewise kept apart from +`revenue_change` and `cash_requirement_change` (PSNCR). + +**Units.** Three of the four quantities look like "percent" and are not +one thing, so each carries its own unit concept: +`percent_of_real_gdp` (a deviation in the LEVEL of real GDP), +`percentage_points` (the AB2025 package's effect on CPI inflation) and +`percent_of_potential_gdp` (briefing paper No.10's supply-side +scorings); borrowing is `gbp`. The mapping is validate-THEN-map: the +adapter's staged unit label must equal the one the metric carries or the +ingest raises, so a mislabeled row can never be stored as a different +quantity. + +**Baselines.** These claims are NOT scored against `current_law`. OBR +measures a package as a deviation from that EFO round's PRE-MEASURES +forecast — a distinct named world per round — and briefing paper No.10 +chapter 2 splits the counterfactual KIND further: tax and welfare +measures against a legislated-parameter counterfactual, DEL and +regulatory measures against the pre-existing activity/spending baseline. +March 2026 Table B.1 states its own counterfactual in its title (the +November 2025 Budget forecast). Each row carries the world and kind as a +`ReformRef.baseline` descriptor with `conditions["baseline_policy"]` +mirroring it, a locator travels with the claim, an event whose baseline +does not match its round raises, and all eleven (round, counterfactual) +worlds are registered in `baselines.py`. Defaulting to `current_law` +would have let a PE result computed against current law read as +comparable. + +**Publication.** Provenance is per vendored artifact, so each round's +claims carry their own release date and dated URL (2023-11-22, +2024-03-06, 2024-10-30, 2025-11-26, 2026-03-03) rather than one generic +`obr.uk/publications/` stamp — and the March 2026 rows carry the +publication date, not the Wayback capture. + +All 266 are `held_out`: nothing in pe-uk-data or policyengine-uk is +fitted to a macro-effect path — they are what the Macro members get +scored against. `basis` is `forecast` on every row (its repo-wide +meaning); how the effect was scored rides in +`conditions["scoring_method"]` (`post_behavioural` | `supply_side`). + +Two identity decisions this population forced: + +- **`conditions["decomposition"]`.** The October 2024 workbook prints + the AB2024 package twice — chart 2.A by expenditure component, 2.B by + measure/channel — so both publish a `total` and a + `demand_multipliers`. The decomposition is therefore identity-bearing, + not provenance, and is keyed off (fiscal_event, sheet): the sheet id + alone will not do, since C2.A is by-channel in the Nov 2023 and Mar + 2024 workbooks. +- **The supply-side horizon, per scoring round.** Briefing paper No.10's + Table 2.1 states its year in words ("the impact on potential output in + the fifth year of our forecast"), never as a digit — and the paper + re-states scorings made at five EARLIER fiscal events, so "our + forecast" is each measure's own round. `_BP10_HORIZON` resolves the + symbol per event (2027-28 for March 2023 through 2029-30 for March + 2025); the note rides verbatim in `conditions["horizon_note"]` and + `conditions["horizon"]` names the symbol. Period is claim identity, so + one shared horizon would have been 19 wrong claims. + +Table B.1's nested rows keep the `aggregate_level`/`parent` guard the +OBR welfare lines use, so no consumer summing borrowing effects by FY +double-counts. PE counterparts are step 3 of #55 and are not computed +here. diff --git a/scorecard_db/baselines.py b/scorecard_db/baselines.py index 6303e19..362b85a 100644 --- a/scorecard_db/baselines.py +++ b/scorecard_db/baselines.py @@ -192,6 +192,201 @@ "with two-child limit and family premium removal fully rolled " "out' (sources/harvest-uk-2026-08-02/uk_ifs).", ), + # --- OBR published policy effects (#55) --------------------------- + # OBR does NOT score a package against "the law in force" in the + # abstract: it scores against that EFO round's PRE-MEASURES forecast + # — a distinct, named world per round, which also fixes the economic + # determinants the effect is measured off. Two rounds' pre-measures + # worlds are different worlds, so a PE result computed against + # current law is NOT comparable to these claims unless it says so. + # Briefing paper No.10 chapter 2 splits the counterfactual KIND, so + # the kind is a descriptor key rather than a blurred single world: + # policy_parameters the measures' pre-announcement legislated + # parameters (tax and welfare measures, and every + # package-level chart) + # del_activity the pre-existing DEL activity/spending level — + # a departmental programme's counterfactual is + # the activity already assumed, not a parameter + # regulatory the pre-existing regulatory baseline (the + # National Planning Policy Framework as it stood) + # Only the (round, kind) pairs the data actually keys are registered; + # the registry is a record of decisions, not a cross-product. + ( + { + "policy": "obr_pre_measures_spring_budget_2023", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_spring_budget_2023__policy_parameters", + "OBR's pre-measures March 2023 forecast, the world the " + "Spring Budget 2023 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_spring_budget_2023", + "counterfactual": "del_activity", + }, + "obr_pre_measures_spring_budget_2023__del_activity", + "OBR's pre-measures March 2023 forecast, the world the " + "Spring Budget 2023 package and its individually scored measures " + "are measured against; counterfactual kind: the pre-existing DEL activity/spending baseline.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_autumn_statement_2023", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_autumn_statement_2023__policy_parameters", + "OBR's pre-measures November 2023 forecast, the world the " + "Autumn Statement 2023 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_autumn_statement_2023", + "counterfactual": "del_activity", + }, + "obr_pre_measures_autumn_statement_2023__del_activity", + "OBR's pre-measures November 2023 forecast, the world the " + "Autumn Statement 2023 package and its individually scored measures " + "are measured against; counterfactual kind: the pre-existing DEL activity/spending baseline.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_spring_budget_2024", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_spring_budget_2024__policy_parameters", + "OBR's pre-measures March 2024 forecast, the world the " + "Spring Budget 2024 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_autumn_budget_2024", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_autumn_budget_2024__policy_parameters", + "OBR's pre-measures October 2024 forecast, the world the " + "Autumn Budget 2024 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_autumn_budget_2024", + "counterfactual": "del_activity", + }, + "obr_pre_measures_autumn_budget_2024__del_activity", + "OBR's pre-measures October 2024 forecast, the world the " + "Autumn Budget 2024 package and its individually scored measures " + "are measured against; counterfactual kind: the pre-existing DEL activity/spending baseline.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_spring_statement_2025", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_spring_statement_2025__policy_parameters", + "OBR's pre-measures March 2025 forecast, the world the " + "Spring Statement 2025 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_spring_statement_2025", + "counterfactual": "regulatory", + }, + "obr_pre_measures_spring_statement_2025__regulatory", + "OBR's pre-measures March 2025 forecast, the world the " + "Spring Statement 2025 package and its individually scored measures " + "are measured against; counterfactual kind: the pre-existing regulatory baseline.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_pre_measures_autumn_budget_2025", + "counterfactual": "policy_parameters", + }, + "obr_pre_measures_autumn_budget_2025__policy_parameters", + "OBR's pre-measures November 2025 forecast, the world the " + "Autumn Budget 2025 package and its individually scored measures " + "are measured against; counterfactual kind: the measures' pre-announcement legislated parameters.", + "policy_ref", + "sources/obr-policy-effects/source.json (baseline_key) plus the " + "per-row baseline / baseline_counterfactual / baseline_locator " + "the adapter emits from the vendored workbooks; descriptor " + "stamped by scorecard_db/ingest_obr_policy_effects.py " + "(_baseline_descriptor).", + ), + ( + { + "policy": "obr_november_2025_budget_forecast", + "counterfactual": "policy_parameters", + }, + "obr_november_2025_budget_forecast__policy_parameters", + "The OBR's November 2025 Budget forecast — the counterfactual " + "March 2026 Table B.1 names in its own title ('Total effect of " + "Government decisions SINCE the November 2025 Budget'). A " + "post-measures world, deliberately not a pre-measures one.", + "policy_ref", + "EFO March 2026, Annex B Table B.1 title, vendored at " + "sources/obr-policy-effects/raw/efo_march2026_annex_tables.xlsx; " + "descriptor stamped by " + "scorecard_db/ingest_obr_policy_effects.py (_baseline_descriptor).", + ), ( {"policy": "pre_ab2025"}, "pre_ab2025", diff --git a/scorecard_db/build_db.py b/scorecard_db/build_db.py index 2b77b7b..c1a7eb8 100644 --- a/scorecard_db/build_db.py +++ b/scorecard_db/build_db.py @@ -19,6 +19,7 @@ campaign_us staged day-1/day-2 campaign results (claim matching) uk_externals five UK primary-source families + Ledger staging uk_deductions FRR family + uk_policy_effects OBR published economic effects of policy produce_uk + campaign_uk archive-resolved UK reckoner attaches Usage: @@ -37,6 +38,7 @@ ingest_campaign, ingest_diagnoses, ingest_harvest, + ingest_obr_policy_effects, ingest_platform, ingest_reform_validation, ingest_solo, @@ -47,6 +49,45 @@ ) +def _assert_every_imported_ingest_runs(steps) -> None: + """Every ingest module imported at the top must appear in `steps`. + + The chain is one ordered list that many branches add to at the same + anchor, so `build_db.py` conflicts on nearly every merge — and the + conflict region splits a step tuple, which means a careless + resolution either breaks the syntax (loud) or DROPS A STEP (silent). + A silently dropped step ships a database missing a whole lane while + every test still passes, because the tests that would notice are the + ones the dropped ingest brought with it. + + So the import list is the contract: if a module is imported here it + must run here. Adding an ingest means adding both, and removing one + means removing both, deliberately. + """ + registered = set() + for _, fn in steps: + registered.update( + name + for name in fn.__code__.co_names + if name.startswith(("ingest_", "produce_")) + ) + imported = { + name + for name in globals() + if name.startswith(("ingest_", "produce_")) + and hasattr(globals()[name], "__file__") + } + missing = sorted(imported - registered) + if missing: + raise SystemExit( + f"build_db imports {missing} but never runs them. Either the " + "chain lost a step in a merge resolution (build_db.py conflicts " + "on nearly every branch, and the conflict splits a step tuple), " + "or the import is dead. Both are decided deliberately, never " + "left to a silently shorter database." + ) + + def content_hash(db_path: Path) -> str: """Order-insensitive logical hash over every table (autoincrement ids excluded) — two builds from the same inputs must match.""" @@ -89,12 +130,17 @@ def build(db_path: Path) -> dict: ("campaign_us", lambda: ingest_campaign.ingest(db_path)), ("uk_externals", lambda: ingest_uk_externals.ingest(db_path)), ("uk_deductions", lambda: ingest_uk_deductions.ingest(db_path)), + ( + "uk_policy_effects", + lambda: ingest_obr_policy_effects.ingest(db_path), + ), ("produce_uk", lambda: produce_campaign_uk.produce(db_path)), ( "campaign_uk", lambda: ingest_campaign.ingest(db_path, produce_campaign_uk.RESOLVED), ), ] + _assert_every_imported_ingest_runs(steps) summary: dict = {"steps": {}} for name, fn in steps: summary["steps"][name] = fn() diff --git a/scorecard_db/ingest_obr_policy_effects.py b/scorecard_db/ingest_obr_policy_effects.py new file mode 100644 index 0000000..1a14852 --- /dev/null +++ b/scorecard_db/ingest_obr_policy_effects.py @@ -0,0 +1,596 @@ +"""Ingest OBR's published policy-effect claims into the scorecard DB (#55). + +The external side of the Macro entry point, harvested by +sources/obr-policy-effects/adapter.py into +data/externals/obr-policy-effects.json: what OBR says fiscal policy DOES +to the economy — package effects on real GDP and CPI, per-measure +supply-side impacts on potential output, and the direct/indirect split of +the decisions' effect on borrowing. These are the claims the Macro +members (OBR emulator, OG-UK, PE-UK LSR) answer in step 3; nothing here +computes a counterpart. + +Same contract as ingest_uk_externals (scorecard_db/README.md): FAIL +LOUDLY on any unmapped metric, unknown identity value or unhandled +adapter field; values arrive in raw units from the adapter and are NEVER +re-derived here; calibration_relationship is decided in relationships.py +(uk_relationship), never inline. + +Four claim families, one source id (``obr_policy_effects``): + + gdp_level_effect 151 per cent of real GDP, by channel or + expenditure component, per FY + cpi_inflation_effect 36 per cent, by measure, per FY + supply_side_impact 19 per cent of potential output, one + per measure (briefing paper No.10) + decisions_effect_on_borrowing 60 GBP, March 2026 Table B.1 nested + +Reform worlds. Every row is a policy EFFECT, so each carries a +``policy_ref`` reform naming the world scored — the fiscal event's +package for the package families, the individual measure for the +supply-side family — AND the baseline world it is scored against. + +That baseline is NOT the null ``current_law``. OBR measures a package as +a deviation from that EFO round's PRE-MEASURES forecast, which is a +distinct named world per round (and fixes the economic determinants the +effect is read off); Briefing paper No.10 chapter 2 further scores +tax/welfare measures against a legislated-parameter counterfactual but +DEL and regulatory measures against the pre-existing activity/spending +baseline; and March 2026 Table B.1 states its own counterfactual in its +title — the November 2025 Budget forecast. The adapter emits each row's +world and counterfactual kind with a locator; this module turns them +into ``ReformRef.baseline`` descriptors, mirrors them into +``conditions["baseline_policy"]``, and every one of them is registered in +baselines.py. Defaulting these to ``current_law`` would have let a PE +result computed against current law read as comparable. + +Hierarchy. Table B.1's nested rows carry the adapter's +``aggregate_level``/``parent`` guard into conditions, the same way the +OBR welfare lines do: without them a consumer summing +decisions_effect_on_borrowing by FY would double-count +(total_effect + direct_effects + their components). + +Period. The three chart families publish a financial-year path and land +on the FY end year. The briefing paper's supply-side table publishes ONE +number per measure, whose year the table states in words, not digits: +"Supply-side impact is the impact on potential output in the fifth year +of our forecast." Crucially that is each measure's OWN scoring round's +forecast, not the November 2025 one the paper is published with: +Briefing paper No.10 re-states scorings made at five earlier fiscal +events, and an AS2023 measure's impact is its effect in the fifth year of +the November 2023 forecast (2028-29), not of the November 2025 forecast. +Period is claim identity, so all 19 rows keyed to one shared 2030-31 +would have been 19 wrong claims. _BP10_HORIZON resolves the symbol per +event (the round's own forecast horizon = its current FY + 5, the +convention every EFO follows); ``horizon`` names the symbol and +``horizon_note`` carries the publication's words verbatim. + +Usage: + PYTHONPATH=. python -m scorecard_db.ingest_obr_policy_effects data/scorecard.db +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .db import LANE_SQL, SCORES_SQL, ScorecardDB +from .harvest import REPO, finish, policy_ref, require_fields, with_baseline_condition +from .models import ExternalScore, Metric, TimeBasis, UnitConcept +from .relationships import uk_relationship +from .uk_aliases import canon + +EXTERNALS = REPO / "data" / "externals" + +# The adapter's file-level slug vs the DB source id (underscored, like +# every other source in external_scores). +ADAPTER_SOURCE = "obr-policy-effects" +SOURCE = "obr_policy_effects" +LANE_ID = "obr-policy-effects" +# This lane's own transition date. +LANE_UPDATED = "2026-08-20" +# The feed's TOP-LEVEL stamp is whatever the last sync_lane_feed caller +# passes, and the UK ingest family pins it to one constant +# (ingest_uk_externals, ingest_uk_deductions). It must stay that +# constant here too: the build ends on this step, the suite ends on +# whichever UK ingest test runs last, and data/lanes.json is committed — +# all three have to agree or the no-drift gate fails. +FEED_UPDATED = "2026-08-19" + +# Publication provenance, keyed by the VENDORED ARTIFACT each row names. +# Publication is per fiscal event: one generic obr.uk/publications/ URL +# stamped with the newest round's date misdated every earlier round's +# claims, and the March 2026 rows carried the Wayback CAPTURE date +# (2026-03-16) instead of the publication date (2026-03-03). Dates are +# the OBR release dates; the captures are recorded in +# sources/obr-policy-effects/raw/README.md and are not publication dates. +PUBLICATIONS = { + "efo_november2023_chapter2.xlsx": { + "title": ( + "OBR Economic and fiscal outlook – November 2023, Chapter 2 " + "charts and tables (Chart 2.A: real GDP impacts of the Autumn " + "Statement 2023 package)" + ), + "url": ( + "https://obr.uk/docs/dlm_uploads/" + "Chapter_2_charts_and_tables_November_2023.xlsx" + ), + "date": "2023-11-22", + }, + "efo_march2024_chapter2.xlsx": { + "title": ( + "OBR Economic and fiscal outlook – March 2024, Chapter 2 " + "charts and tables (Chart 2.A: impact of policy measures on " + "real GDP)" + ), + "url": ( + "https://obr.uk/docs/dlm_uploads/" + "Chapter_2_charts_and_tables_March_2024.xlsx" + ), + "date": "2024-03-06", + }, + "efo_october2024_chapter2.xlsx": { + "title": ( + "OBR Economic and fiscal outlook – October 2024, Chapter 2 " + "charts and tables (Charts 2.A and 2.B: policy impacts on real " + "GDP, by component and by measure)" + ), + "url": ( + "https://obr.uk/docs/dlm_uploads/" + "Chapter_2_charts_and_tables_October_2024.xlsx" + ), + "date": "2024-10-30", + }, + "efo_november2025_chapter3.xlsx": { + "title": ( + "OBR Economic and fiscal outlook – November 2025, Chapter 3 " + "charts and tables (Charts 3.3 and 3.4: policy impacts on real " + "GDP and on CPI inflation)" + ), + "url": ( + "https://obr.uk/docs/dlm_uploads/" + "Chapter_3_charts_and_tables_November_2025.xlsx" + ), + "date": "2025-11-26", + }, + "efo_march2026_annex_tables.xlsx": { + "title": ( + "OBR Economic and fiscal outlook – March 2026, Annex B Table " + "B.1: total effect of Government decisions on borrowing" + ), + "url": ( + "https://obr.uk/docs/d055fbf02d5b3g6jq8l2/" + "efo-march-2026-charts-and-tables-annex-tables.xlsx" + ), + "date": "2026-03-03", + }, + "obr_briefing_paper_10_supply_side.xlsx": { + "title": ( + "OBR Briefing paper No.10: accounting for the supply-side " + "effects of policy measures, Table 2.1" + ), + "url": ( + "https://obr.uk/docs/dlm_uploads/Briefing_paper_No.10_Accounting_" + "for_the_supply-side_effects_of_policy_measures_charts_and_" + "tables.xlsx" + ), + "date": "2025-11-26", + }, +} + +# Briefing paper No.10 (November 2025) T2.1 note, verbatim: "Supply-side +# impact is the impact on potential output in the fifth year of our +# forecast." The paper RE-STATES scorings made at five earlier fiscal +# events, so "our forecast" is each measure's own scoring round — not the +# November 2025 round the paper accompanies. Every EFO forecast runs the +# current financial year plus five, so the fifth forecast year of a round +# held in FY Y is FY Y+5: +# +# March 2023 scored in FY2022-23 -> 2027-28 +# November 2023 scored in FY2023-24 -> 2028-29 +# March 2024 scored in FY2023-24 -> 2028-29 +# October 2024 scored in FY2024-25 -> 2029-30 +# March 2025 scored in FY2024-25 -> 2029-30 +# +# Period is claim identity: keying all 19 rows to 2030-31 (the November +# 2025 round's horizon) made 19 claims about years OBR never scored them +# for. The mapping is stated here, pinned by tests, and re-keyable in one +# place if a reviewer reads a horizon differently. +_BP10_HORIZON_NOTE = ( + "Supply-side impact is the impact on potential output in the fifth " + "year of our forecast." +) +_BP10_HORIZON = { + "spring_budget_2023": ("2027-28", 2028), + "autumn_statement_2023": ("2028-29", 2029), + "spring_budget_2024": ("2028-29", 2029), + "autumn_budget_2024": ("2029-30", 2030), + "spring_statement_2025": ("2029-30", 2030), +} + +# metric -> (Metric, adapter unit expected, UnitConcept, value_kind). +# VALIDATE-THEN-MAP (the #52 pattern): the adapter's staged unit label is +# checked against the one this metric must carry and RAISES on drift. +# Previously the staged label was canon-checked and then DISCARDED, so a +# GDP row mislabeled 'gbp_nominal' would have been stored as a percent. +_METRICS = { + "gdp_level_effect": ( + Metric.GDP_LEVEL_EFFECT, + "percent_of_real_gdp", + UnitConcept.PERCENT_OF_REAL_GDP, + "percent", + ), + "cpi_inflation_effect": ( + Metric.CPI_INFLATION_EFFECT, + "percentage_points", + UnitConcept.PERCENTAGE_POINTS, + "percent", + ), + "supply_side_impact": ( + Metric.SUPPLY_SIDE_IMPACT, + "percent_of_potential_gdp", + UnitConcept.PERCENT_OF_POTENTIAL_GDP, + "percent", + ), + "decisions_effect_on_borrowing": ( + Metric.DECISIONS_EFFECT_ON_BORROWING, + "gbp_nominal", + UnitConcept.GBP, + "gbp", + ), +} + +# Every top-level field the adapter emits. Unknown fields raise (the +# harvest require_fields contract): a new adapter column is handled here +# DELIBERATELY or not at all. +_KNOWN_FIELDS = frozenset( + { + "aggregate_level", + "artifact", + "baseline", + "baseline_counterfactual", + "baseline_locator", + "basis", + "country", + "description", + "fiscal_event", + "geography", + "metric", + "parent", + "period", + "program", + "scope", + "scoring_method", + "sign_convention", + "source", + "source_column", + "status", + "subgroup", + "unit_concept", + "value", + "variant", + } +) + +# Closed vocabularies for the free-text-shaped condition axes: an +# unrecognised value is a re-shuffled or re-labelled publication, which +# must fail rather than mint a claim under a new identity. +_EVENTS = frozenset( + { + "spring_budget_2023", + "autumn_statement_2023", + "spring_budget_2024", + "autumn_budget_2024", + "spring_statement_2025", + "autumn_budget_2025", + "march_2026_efo", + } +) +# `basis` is the repo-wide forecast|outturn axis and every row here is a +# forecast quantity. HOW the effect was scored is a different question +# and gets its own axis — a scoring method was squatting on `basis`. +_BASES = frozenset({"forecast"}) +_SCORING_METHODS = frozenset({"post_behavioural", "supply_side"}) + +# The baseline worlds these claims are scored against, closed. Every one +# is registered in baselines.py, and the registration gate runs inside +# the same transaction as the claims — an unregistered world rolls the +# whole replacement back. +# fiscal_event -> the ONE baseline world its rows may key. A row whose +# event and baseline disagree is exactly the failure this fixes: the +# registration gate passes while the claims name the wrong world. +_EVENT_BASELINE = { + "spring_budget_2023": "obr_pre_measures_spring_budget_2023", + "autumn_statement_2023": "obr_pre_measures_autumn_statement_2023", + "spring_budget_2024": "obr_pre_measures_spring_budget_2024", + "autumn_budget_2024": "obr_pre_measures_autumn_budget_2024", + "spring_statement_2025": "obr_pre_measures_spring_statement_2025", + "autumn_budget_2025": "obr_pre_measures_autumn_budget_2025", + "march_2026_efo": "obr_november_2025_budget_forecast", +} +_BASELINE_POLICIES = frozenset(_EVENT_BASELINE.values()) +_COUNTERFACTUALS = frozenset({"policy_parameters", "del_activity", "regulatory"}) + +# (fiscal_event, published sheet) -> how that chart decomposes the effect. +# This axis is IDENTITY-BEARING, not provenance: the October 2024 +# workbook prints the AB2024 package twice — chart 2.A splits it by +# expenditure component, chart 2.B by measure/channel — so both publish a +# 'total' and both publish 'demand_multipliers'. Without the +# decomposition the two totals collide on one claim_id (they agree to +# ~1e-14, being the same series rendered twice) and the two +# demand_multipliers rows claim to be one quantity when they are not. +# The sheet id alone will not do: C2.A is by-channel in the Nov 2023 and +# Mar 2024 workbooks and by-expenditure-component in Oct 2024. +_DECOMPOSITIONS = { + ("autumn_statement_2023", "C2.A"): "channel", + ("spring_budget_2024", "C2.A"): "channel", + ("autumn_budget_2024", "C2.A"): "expenditure_component", + ("autumn_budget_2024", "C2.B"): "channel", + ("autumn_budget_2025", "C3.3"): "expenditure_component", + ("autumn_budget_2025", "C3.4"): "measure", + ("spring_budget_2023", "T2.1"): "supply_side_channel", + ("autumn_statement_2023", "T2.1"): "supply_side_channel", + ("spring_budget_2024", "T2.1"): "supply_side_channel", + ("autumn_budget_2024", "T2.1"): "supply_side_channel", + ("spring_statement_2025", "T2.1"): "supply_side_channel", + ("march_2026_efo", "TB.1"): "fiscal_aggregate", +} +_SCOPES = frozenset({"package", "measure"}) +_AGGREGATE_LEVELS = frozenset({"component", "subtotal", "total"}) +_MEASURE_TYPES = frozenset({"del", "tax", "welfare", "regulation"}) +_SIGN_CONVENTIONS = frozenset({"as_published_positive_increases"}) + + +def _load() -> list[dict]: + path = EXTERNALS / f"{ADAPTER_SOURCE}.json" + if not path.exists(): + raise FileNotFoundError( + f"{path} missing — run sources/obr-policy-effects/adapter.py first" + ) + rows = json.loads(path.read_text()) + for row in rows: + require_fields(row, _KNOWN_FIELDS, SOURCE) + if row["source"] != ADAPTER_SOURCE: + raise ValueError( + f"{SOURCE}: row source {row['source']!r} is not {ADAPTER_SOURCE!r}" + ) + if row["country"] != "UK": + raise ValueError(f"{SOURCE}: row country {row['country']!r} is not 'UK'") + return rows + + +def _closed(value, allowed, axis): + if value not in allowed: + raise ValueError( + f"{SOURCE}: unregistered {axis} {value!r} — add it deliberately, " + "never pass it through" + ) + return value + + +def _fy(label: str) -> tuple[int, str]: + """'2023-24' -> (2024, '2023-24'). Financial years as published. + + fullmatch, and the suffix must be the start year + 1: '2029-99' is + malformed, never year 2030 (the ingest_uk_externals gate).""" + import re + + m = re.fullmatch(r"(\d{4})-(\d{2})", label) + if not m: + raise ValueError(f"unparseable OBR financial-year label: {label!r}") + start = int(m.group(1)) + if (start + 1) % 100 != int(m.group(2)): + raise ValueError( + f"OBR financial-year label {label!r}: suffix is not start year + 1" + ) + return start + 1, f"{start}-{m.group(2)}" + + +def _decomposition(row: dict) -> str: + """How the row's published chart splits the effect (see + _DECOMPOSITIONS). The sheet comes from the adapter's verbatim + 'SHEET:series' source_column; an unregistered (event, sheet) pair + raises rather than minting claims under an unknown decomposition.""" + sheet = row["source_column"].split(":", 1)[0] + key = (row["fiscal_event"], sheet) + if key not in _DECOMPOSITIONS: + raise ValueError( + f"{SOURCE}: unregistered (fiscal_event, sheet) {key} — map its " + "decomposition in _DECOMPOSITIONS deliberately" + ) + return _DECOMPOSITIONS[key] + + +def _baseline_descriptor(row: dict) -> dict: + """The world this row is scored AGAINST, from the adapter's own + per-row declaration — never a default. + + OBR measures a package as a deviation from its round's pre-measures + forecast, and Briefing paper No.10 scores DEL and regulatory measures + against the pre-existing activity baseline rather than a legislated + parameter, so the counterfactual KIND is part of the world's + identity. Both values are closed; the locator travels with the claim + as provenance. + """ + policy = _closed(row["baseline"], _BASELINE_POLICIES, "baseline policy") + expected = _EVENT_BASELINE[_closed(row["fiscal_event"], _EVENTS, "fiscal_event")] + if policy != expected: + raise ValueError( + f"{SOURCE}: {row['fiscal_event']} rows are scored against " + f"{expected!r}, not {policy!r} — each round has its own " + "pre-measures world and they are not interchangeable" + ) + kind = _closed( + row["baseline_counterfactual"], _COUNTERFACTUALS, "baseline counterfactual" + ) + if not str(row.get("baseline_locator", "")).strip(): + raise ValueError( + f"{SOURCE}: baseline {policy!r} carries no locator — a registered " + "world without a citable reading is not provenance" + ) + return {"policy": policy, "counterfactual": kind} + + +def _reform(row: dict): + """The policy world the row scores. + + Package families score the fiscal event's whole announced package; + the supply-side family scores one measure inside an event. The two + never share a slug, and a measure's slug carries its event — the + same measure re-scored at a later event is a different world. + """ + event = row["fiscal_event"] + baseline = _baseline_descriptor(row) + if row["scope"] == "package": + return policy_ref(f"obr_{event}_package", baseline=baseline) + return policy_ref(f"obr_{event}_{row['program']}", baseline=baseline) + + +def stage() -> tuple[list[ExternalScore], dict]: + """Stage every row; nothing touches the DB here.""" + scores: list[ExternalScore] = [] + counts: dict[str, int] = {} + for row in _load(): + staged_unit = canon(SOURCE, "unit", row["unit_concept"]) + metric, expected_unit, unit, value_kind = _METRICS[row["metric"]] + # validate-THEN-map: the staged label must be the one this metric + # carries, or the row is a mislabeled quantity and must not land + if staged_unit != expected_unit: + raise ValueError( + f"{SOURCE}: {row['metric']} staged unit {staged_unit!r} != " + f"{expected_unit!r} — the adapter's unit label and the metric's " + "unit concept disagree; fix the label, never map past it" + ) + cond = { + "country": "UK", + "geography": canon(SOURCE, "geography", row["geography"]), + "program": canon(SOURCE, "program", row["program"]), + "fiscal_event": _closed(row["fiscal_event"], _EVENTS, "fiscal_event"), + "basis": _closed(row["basis"], _BASES, "basis"), + "scoring_method": _closed( + row["scoring_method"], _SCORING_METHODS, "scoring_method" + ), + "scope": _closed(row["scope"], _SCOPES, "scope"), + # the roll-up guard: without it the nested Table B.1 rows read + # as siblings and any consumer summing by FY double-counts + "aggregate_level": _closed( + row["aggregate_level"], _AGGREGATE_LEVELS, "aggregate_level" + ), + "decomposition": _decomposition(row), + } + if row["parent"] is not None: + cond["parent"] = canon(SOURCE, "program", row["parent"]) + if row["subgroup"] != "total": + cond["subgroup"] = canon(SOURCE, "subgroup", row["subgroup"]) + if row["variant"] is not None: + cond["measure_type"] = _closed( + row["variant"], _MEASURE_TYPES, "measure_type" + ) + if row.get("sign_convention") is not None: + cond["sign_convention"] = _closed( + row["sign_convention"], _SIGN_CONVENTIONS, "sign_convention" + ) + if row["period"] == "forecast_horizon": + if row["metric"] != "supply_side_impact": + raise ValueError( + f"{SOURCE}: period 'forecast_horizon' on {row['metric']!r} — " + "only the briefing-paper supply-side table publishes a " + "horizon-terminal number" + ) + if row["fiscal_event"] not in _BP10_HORIZON: + raise ValueError( + f"{SOURCE}: no forecast horizon registered for scoring " + f"event {row['fiscal_event']!r} — a horizon-terminal " + "number needs its own round's fifth forecast year, not " + "another round's" + ) + fy, period = _BP10_HORIZON[row["fiscal_event"]] + cond["horizon"] = "fifth_year_of_scoring_round_forecast" + cond["horizon_note"] = _BP10_HORIZON_NOTE + else: + period, fy = _fy(row["period"]) + cond["fy"] = fy + reform = _reform(row) + with_baseline_condition(cond, reform) + if row["artifact"] not in PUBLICATIONS: + raise ValueError( + f"{SOURCE}: no publication registered for artifact " + f"{row['artifact']!r} — publication provenance is per " + "artifact, never one generic stamp" + ) + scores.append( + ExternalScore( + source=SOURCE, + metric=metric, + unit_concept=unit, + period=period, + time_basis=TimeBasis.FISCAL_YEAR, + value=row["value"], + conditions=cond, + reform=reform, + calibration_relationship=uk_relationship( + SOURCE, metric, program=cond["program"], kind=row["scoring_method"] + )[0], + source_model="obr_efo", + source_column=row["source_column"], + publication=PUBLICATIONS[row["artifact"]], + value_kind=value_kind, + status=row["status"], + ) + ) + counts[row["metric"]] = counts.get(row["metric"], 0) + 1 + return finish(scores, SOURCE), counts + + +# Exact accounting for the committed adapter output. A drifted +# regeneration must fail HERE, never grow or shrink the catalog silently. +_EXPECTED = { + "gdp_level_effect": 151, + "cpi_inflation_effect": 36, + "supply_side_impact": 19, + "decisions_effect_on_borrowing": 60, +} + + +def ingest(db_path: Path) -> dict: + """Stage and validate first; then ONE transaction replaces this + source wholesale and runs the baseline-registration gate inside it, + exactly as ingest_uk_externals does. The lane-feed mirror is + rewritten after the commit (idempotent, keyed by lane id).""" + scores, counts = stage() + if counts != _EXPECTED: + raise ValueError(f"claim accounting drifted: {counts} != {_EXPECTED}") + db = ScorecardDB(db_path) + rows = [ScorecardDB.score_row(s) for s in scores] + from .baselines import register_baselines_txn + from .ingest_harvest import sync_lane_feed + + with db.conn: + db.conn.execute("DELETE FROM external_scores WHERE source = ?", (SOURCE,)) + db.conn.executemany(SCORES_SQL, rows) + register_baselines_txn(db) + db.conn.execute( + LANE_SQL, + (LANE_ID, "ingested", f"{len(rows)} claims", LANE_UPDATED), + ) + sync_lane_feed( + db, + REPO / "data" / "lanes.json", + FEED_UPDATED, + lanes={ + LANE_ID: { + "source": "OBR", + "area": "published economic effects of policy", + "mode": 2, + "country": "UK", + } + }, + ) + db.close() + return {"claims": len(rows), "by_metric": dict(sorted(counts.items()))} + + +if __name__ == "__main__": + import sys + + out = Path(sys.argv[1] if len(sys.argv) > 1 else "data/scorecard.db") + print(json.dumps(ingest(out), indent=1)) diff --git a/scorecard_db/models.py b/scorecard_db/models.py index 96432cc..9cdfa6c 100644 --- a/scorecard_db/models.py +++ b/scorecard_db/models.py @@ -90,6 +90,20 @@ class Metric(str, Enum): CASH_REQUIREMENT_CHANGE = "cash_requirement_change" GAINER_COUNT = "gainer_count" AVERAGE_ANNUAL_GAIN = "average_annual_gain" + # OBR published policy effects (#55): what policy does to the ECONOMY, + # not to a household or the exchequer's take. gdp_level_effect and + # supply_side_impact are both per cent of output and deliberately + # DISTINCT: the first is the package's effect on real (actual) GDP + # along the forecast path, the second one measure's effect on + # POTENTIAL output at the horizon — unifying them would merge a + # demand-inclusive path with a supply-side scoring. + # decisions_effect_on_borrowing is PSNB, kept distinct from + # revenue_change (a receipts line) and cash_requirement_change + # (PSNCR) for the same unconfusability reason. + GDP_LEVEL_EFFECT = "gdp_level_effect" + CPI_INFLATION_EFFECT = "cpi_inflation_effect" + SUPPLY_SIDE_IMPACT = "supply_side_impact" + DECISIONS_EFFECT_ON_BORROWING = "decisions_effect_on_borrowing" class UnitConcept(str, Enum): @@ -132,6 +146,23 @@ class UnitConcept(str, Enum): # same rule: averages must never be summable as aggregates). The # FRR family's £420 average annual gain is per household per year. GBP_PER_HOUSEHOLD = "gbp_per_household" + # OBR macro policy effects (#55). Three quantities that all LOOK like + # "percent" and must never be summed, averaged or compared as one: + # PERCENT_OF_REAL_GDP per cent deviation in the LEVEL of real + # GDP (or of an expenditure component) + # along the forecast path + # PERCENTAGE_POINTS effect on a RATE — OBR publishes the + # AB2025 package's CPI inflation impact + # in pp, not as a level deviation + # PERCENT_OF_POTENTIAL_GDP impact on POTENTIAL output, per cent of + # GDP (briefing paper No.10 supply-side + # scorings) — a supply concept, not the + # demand-inclusive actual-GDP path + # Same rule that split GBP_PER_WEEK from bare GBP: a mislabeled unit + # misstates what the number is. + PERCENT_OF_REAL_GDP = "percent_of_real_gdp" + PERCENTAGE_POINTS = "percentage_points" + PERCENT_OF_POTENTIAL_GDP = "percent_of_potential_gdp" # Standardized conditions vocabulary (COLLATION worklist item 4). @@ -242,6 +273,39 @@ class UnitConcept(str, Enum): "component", "aggregate_level", "parent", + # scoring_method HOW an effect was scored, on sources that publish + # more than one scoring: "post_behavioural" (the + # published post-adjustment path) | "supply_side" + # (a per-measure potential-output scoring). It is + # NOT a basis — `basis` stays forecast|outturn — + # and it is identity-bearing: the same measure's + # demand-inclusive path and supply-side scoring are + # different quantities. + "scoring_method", + # counterfactual the KIND of world a baseline names, where a + # source scores different measure types against + # different counterfactuals: "policy_parameters" + # (legislated-parameter counterfactual) | + # "del_activity" | "regulatory" (pre-existing + # activity/spending baseline). OBR Briefing paper + # No.10 chapter 2 is explicit about the split. + "counterfactual", + # decomposition how a published chart splits an effect + # ("channel" | "expenditure_component" | "measure" | + # "supply_side_channel" | "fiscal_aggregate") + "decomposition", + # measure_type source's own measure classification + # ("tax" | "welfare" | "del" | "regulation") + "measure_type", + # sign_convention the published sign rule, carried verbatim rather + # than normalised ("as_published_positive_increases") + "sign_convention", + # horizon symbolic period on a horizon-terminal number + # ("fifth_year_of_forecast"), beside the resolved + # integer period; horizon_note carries the + # publication's own words + "horizon", + "horizon_note", } ) diff --git a/scorecard_db/relationships.py b/scorecard_db/relationships.py index 31c3d7f..10ca3d6 100644 --- a/scorecard_db/relationships.py +++ b/scorecard_db/relationships.py @@ -228,6 +228,22 @@ def effective_relationship(program, metric): "parameter is fitted to its published statistics.", ) +# OBR published policy EFFECTS (#55): package impacts on GDP/CPI, the +# per-measure supply-side scorings, and the decisions' effect on +# borrowing. Distinct from the obr welfare-baseline source, whose +# FY2024-25 outturn column pe-uk-data does consume: nothing in +# pe-uk-data or policyengine-uk reads a macro-effect path — they are +# what the Macro members are scored AGAINST. +_OBR_POLICY_EFFECTS_HELD = ( + CR.HELD_OUT, + "OBR macro/policy-effect estimates are scored, never consumed: no " + "pe-uk-data target and no policyengine-uk parameter is fitted to a " + "GDP/CPI impact path, a supply-side scoring, or the decisions' " + "effect on borrowing (consumption surfaces read 2026-08-19 at the " + "certified pins; the obr welfare source is the only OBR material " + "with a consuming pin).", +) + def uk_relationship(source, metric, program=None, kind=None): """(CalibrationRelationship, basis) for a UK claim, keyed exactly. @@ -276,6 +292,12 @@ def uk_relationship(source, metric, program=None, kind=None): if program in OBR_CONSUMED_WELFARE_PROGRAMS: return _OBR_CONSUMED return _OBR_UNCONSUMED + if source == "obr_policy_effects": + if kind not in ("post_behavioural", "supply_side"): + raise ValueError( + f"obr_policy_effects basis {kind!r} needs a deliberate assignment" + ) + return _OBR_POLICY_EFFECTS_HELD if source == "ukmod": return _UKMOD_HELD if source == "hm_treasury": diff --git a/scorecard_db/uk_aliases.py b/scorecard_db/uk_aliases.py index 33d5a49..8785167 100644 --- a/scorecard_db/uk_aliases.py +++ b/scorecard_db/uk_aliases.py @@ -298,6 +298,105 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: for _src in ("hm_treasury", "dwp"): _identity(_src, "unit", ["gbp", "households"]) +# --- OBR published policy effects (#55) -------------------------------------- +# A macro-effects vocabulary, registered as its OWN source: these slugs +# name policy packages, fiscal-aggregate lines and economic channels, not +# the benefit/tax programs the mode-1 sources share. Nothing here aliases +# into obr's welfare program vocabulary — obr_policy_effects' +# "employer_nics" is a measure whose supply-side effect is scored, not a +# spending line, so unifying the two namespaces would be a category +# error. +_identity("obr_policy_effects", "geography", ["UK"]) +# Three DIFFERENT macro quantities, three unit concepts (the harvest's +# gate finding): a per-cent deviation in the LEVEL of real GDP, an effect +# on CPI inflation in PERCENTAGE POINTS, and an impact on POTENTIAL +# output as a per cent of GDP. Bare "percent" is deliberately NOT +# registered here — it would let the three collapse back into one. +_identity( + "obr_policy_effects", + "unit", + [ + "percent_of_real_gdp", + "percentage_points", + "percent_of_potential_gdp", + "gbp_nominal", + ], +) +_identity( + "obr_policy_effects", + "program", + [ + # the announced package as a whole (the chart families' subject) + "policy_package", + # March 2026 Table B.1 fiscal-aggregate lines (nested; the + # aggregate_level/parent conditions carry the roll-up) + "total_effect", + "direct_effects", + "indirect_effects", + "spending_measures", + "additional_departmental_spending", + "local_authority_support", + "other_spending_measures", + "tax_measures", + "pillar_2_reforms", + "other_tax_measures", + # briefing paper No.10 T2.1: individually scored measures + "free_childcare_30_hours", + "employee_nics_cut", + "employer_nics", + "full_expensing", + "hicbc_threshold", + "individual_placement_and_support", + "pensions_allowances", + "public_investment", + "residential_planning_reforms", + "restart_scheme", + "talking_therapies", + "tax_threshold_freeze", + "uc_conditionality", + "uc_childcare_upfront_costs", + "universal_support", + "universal_support_extension", + "wca_reversal", + "wca_reforms", + ], +) +_identity( + "obr_policy_effects", + "subgroup", + [ + "total", + # supply-side channels (briefing paper T2.1 column F) + "labour", + "capital", + "tfp", + # GDP-impact channels and expenditure components (chart data) + "demand", + "demand_multipliers", + "output_gap", + "consumption", + "private_consumption", + "business_investment", + "government_consumption", + "government_investment", + "government_consumption_and_investment", + "residential_and_business_investment", + "net_trade_and_other", + "supply_child_benefit", + "supply_crowding_out", + "supply_employer_nics", + "supply_full_expensing", + "supply_nics_cut", + "supply_public_investment", + "supply_welfare_reforms_and_other", + # CPI-impact measures (Nov 2025 C3.4) + "energy_bills_package", + "fuel_duty_freeze_extension", + "mileage_based_charge_on_electric_cars", + "rail_fares_freeze", + ], +) + def canon(source: str, axis: str, value: str) -> str: """Canonical value for (source, axis, source_value); unknown raises.""" diff --git a/tests/test_obr_policy_effects_ingest.py b/tests/test_obr_policy_effects_ingest.py new file mode 100644 index 0000000..160e65c --- /dev/null +++ b/tests/test_obr_policy_effects_ingest.py @@ -0,0 +1,706 @@ +"""Tests for the OBR policy-effects -> DB ingest path (#55 step 2). + +Mapping-layer tests run on synthetic rows so they hold on any branch; +the staged-file tests activate once the harvest output (#75) is present +under data/externals/. +""" + +import json + +import pytest + +from scorecard_db import Metric, ScorecardDB, UnitConcept +from scorecard_db.ingest_obr_policy_effects import ( + _BP10_HORIZON, + _EVENT_BASELINE, + EXTERNALS, + PUBLICATIONS, + SOURCE, + _decomposition, + _fy, + stage, +) +from scorecard_db.models import CalibrationRelationship + + +def _row(**over): + base = { + "source": "obr-policy-effects", + "country": "UK", + "program": "policy_package", + "metric": "gdp_level_effect", + "subgroup": "total", + "variant": None, + "geography": "UK", + "unit_concept": "percent_of_real_gdp", + "period": "2026-27", + "value": 0.26, + "status": "ok", + "fiscal_event": "autumn_budget_2025", + "basis": "forecast", + "scoring_method": "post_behavioural", + "scope": "package", + "aggregate_level": "total", + "parent": None, + "artifact": "efo_november2025_chapter3.xlsx", + "baseline": "obr_pre_measures_autumn_budget_2025", + "baseline_counterfactual": "policy_parameters", + "baseline_locator": ( + "EFO November 2025, Chart 3.3: policy impacts on real GDP, " + "measured as deviations from the pre-measures November 2025 " + "forecast." + ), + "source_column": "C3.3:Total", + } + base.update(over) + # each round is scored against its OWN pre-measures world; keep the + # synthetic rows self-consistent the way the adapter's are + base["baseline"] = _EVENT_BASELINE.get(base["fiscal_event"], base["baseline"]) + if "baseline" in over: + base["baseline"] = over["baseline"] + return base + + +def _staged(monkeypatch, rows): + monkeypatch.setattr( + "scorecard_db.ingest_obr_policy_effects._load", lambda: list(rows) + ) + return stage() + + +def _write_externals(monkeypatch, tmp_path, rows): + """Point the module at a temp externals dir holding `rows`, so the + _load() gates run against a real file.""" + (tmp_path / "obr-policy-effects.json").write_text(json.dumps(rows)) + monkeypatch.setattr("scorecard_db.ingest_obr_policy_effects.EXTERNALS", tmp_path) + + +# --- financial-year parsing ------------------------------------------------- + + +def test_fy_parsing(): + assert _fy("2023-24") == (2024, "2023-24") + assert _fy("2030-31") == (2031, "2030-31") + assert _fy("1999-00") == (2000, "1999-00") + with pytest.raises(ValueError, match="unparseable"): + _fy("FYE 2024") + # the suffix must be start + 1 — never silently accept a malformed span + with pytest.raises(ValueError, match="suffix"): + _fy("2029-99") + # fullmatch: a trailing newline must not pass + with pytest.raises(ValueError, match="unparseable"): + _fy("2029-30\n") + + +# --- fail-loud identity gates ---------------------------------------------- + + +def test_unknown_metric_raises(monkeypatch): + with pytest.raises(KeyError): + _staged(monkeypatch, [_row(metric="employment_effect")]) + + +@pytest.mark.parametrize( + "field,value,match", + [ + ("fiscal_event", "autumn_budget_2027", "fiscal_event"), + ("basis", "static", "basis"), + ("scoring_method", "dynamic", "scoring_method"), + ("baseline", "current_law", "baseline policy"), + ("baseline_counterfactual", "vibes", "baseline counterfactual"), + ("scope", "economy", "scope"), + ("aggregate_level", "grand_total", "aggregate_level"), + ], +) +def test_unregistered_closed_axis_raises(monkeypatch, field, value, match): + with pytest.raises(ValueError, match=match): + _staged(monkeypatch, [_row(**{field: value})]) + + +def test_unregistered_subgroup_and_program_raise(monkeypatch): + with pytest.raises(ValueError, match="subgroup"): + _staged(monkeypatch, [_row(subgroup="animal_spirits")]) + with pytest.raises(ValueError, match="program"): + _staged(monkeypatch, [_row(program="growth_package")]) + + +def test_unhandled_adapter_field_raises(monkeypatch, tmp_path): + """require_fields: a new adapter column is handled deliberately or + the ingest stops — never silently dropped.""" + row = _row() + row["confidence_interval"] = "0.1" + _write_externals(monkeypatch, tmp_path, [row]) + with pytest.raises(ValueError, match="unhandled staged fields"): + stage() + + +def test_non_uk_row_raises(monkeypatch, tmp_path): + _write_externals(monkeypatch, tmp_path, [_row(country="US")]) + with pytest.raises(ValueError, match="not 'UK'"): + stage() + + +def test_foreign_source_slug_raises(monkeypatch, tmp_path): + _write_externals(monkeypatch, tmp_path, [_row(source="obr-welfare")]) + with pytest.raises(ValueError, match="is not 'obr-policy-effects'"): + stage() + + +def test_missing_harvest_file_raises(monkeypatch, tmp_path): + monkeypatch.setattr("scorecard_db.ingest_obr_policy_effects.EXTERNALS", tmp_path) + with pytest.raises(FileNotFoundError, match="adapter.py"): + stage() + + +# --- the decomposition axis ------------------------------------------------- + + +def test_decomposition_distinguishes_the_two_ab2024_charts(): + """October 2024 prints the AB2024 package twice: chart 2.A by + expenditure component, 2.B by measure/channel. Both publish 'total' + and 'demand_multipliers', so without this axis the rows collide.""" + a = _decomposition(_row(fiscal_event="autumn_budget_2024", source_column="C2.A:x")) + b = _decomposition(_row(fiscal_event="autumn_budget_2024", source_column="C2.B:x")) + assert a == "expenditure_component" + assert b == "channel" + assert a != b + + +def test_same_sheet_id_can_mean_different_decompositions(): + """C2.A is by-channel in Nov 2023 and by-expenditure-component in + Oct 2024 — the sheet id alone is not the identity.""" + assert ( + _decomposition( + _row(fiscal_event="autumn_statement_2023", source_column="C2.A:x") + ) + == "channel" + ) + assert ( + _decomposition(_row(fiscal_event="autumn_budget_2024", source_column="C2.A:x")) + == "expenditure_component" + ) + + +def test_unregistered_event_sheet_pair_raises(): + with pytest.raises(ValueError, match="unregistered"): + _decomposition(_row(source_column="C9.9:x")) + + +def test_two_ab2024_totals_stage_without_collision(monkeypatch): + rows = [ + _row( + fiscal_event="autumn_budget_2024", + source_column="C2.A:Total", + value=0.0855, + ), + _row( + fiscal_event="autumn_budget_2024", + source_column="C2.B:Total", + value=0.0855, + ), + ] + scores, counts = _staged(monkeypatch, rows) + assert counts == {"gdp_level_effect": 2} + assert len({s.claim_id() for s in scores}) == 2 + + +# --- period handling -------------------------------------------------------- + + +def test_supply_side_horizon_is_named_not_guessed(monkeypatch): + """The briefing paper states the horizon in words; the mapping to a + year is deliberate, carries the verbatim note, and lives in one + place.""" + scores, _ = _staged( + monkeypatch, + [ + _row( + metric="supply_side_impact", + program="restart_scheme", + subgroup="labour", + variant="del", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event="spring_budget_2023", + source_column="T2.1:Restart", + description="Employment support", + ) + ], + ) + (s,) = scores + fy, period = _BP10_HORIZON["spring_budget_2023"] + assert (fy, period) == ("2027-28", 2028) + assert s.period == period + assert s.conditions["fy"] == fy + assert s.conditions["horizon"] == "fifth_year_of_scoring_round_forecast" + assert "fifth year of our forecast" in s.conditions["horizon_note"] + + +def test_supply_side_horizon_is_per_scoring_round(monkeypatch): + """Briefing paper No.10 re-states scorings made at FIVE earlier + events. "The fifth year of our forecast" is each measure's own + round's fifth year, not the November 2025 round's — period is claim + identity, so one shared 2030-31 would be 19 wrong claims.""" + + def measure(event): + return _row( + metric="supply_side_impact", + program="employee_nics_cut", + subgroup="labour", + variant="tax", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event=event, + source_column="T2.1:Employee NICs cut", + description="", + ) + + scores, _ = _staged(monkeypatch, [measure(e) for e in _BP10_HORIZON]) + got = {s.conditions["fiscal_event"]: (s.conditions["fy"], s.period) for s in scores} + assert got == { + "spring_budget_2023": ("2027-28", 2028), + "autumn_statement_2023": ("2028-29", 2029), + "spring_budget_2024": ("2028-29", 2029), + "autumn_budget_2024": ("2029-30", 2030), + "spring_statement_2025": ("2029-30", 2030), + } + # and not one of them is the November 2025 round's horizon + assert 2031 not in {p for _, p in got.values()} + + +def test_supply_side_horizon_for_an_unregistered_round_raises(monkeypatch): + """A horizon-terminal number whose round has no registered horizon + must stop, not borrow another round's year.""" + monkeypatch.delitem(_BP10_HORIZON, "spring_budget_2023") + with pytest.raises(ValueError, match="no forecast horizon registered"): + _staged( + monkeypatch, + [ + _row( + metric="supply_side_impact", + program="employee_nics_cut", + subgroup="labour", + variant="tax", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event="spring_budget_2023", + source_column="T2.1:x", + description="", + ) + ], + ) + + +def test_forecast_horizon_on_a_chart_metric_raises(monkeypatch): + """Only the briefing-paper table publishes a horizon-terminal + number; a chart row arriving without a year is a parse fault.""" + with pytest.raises(ValueError, match="horizon-terminal"): + _staged(monkeypatch, [_row(period="forecast_horizon")]) + + +# --- reform worlds ---------------------------------------------------------- + + +def test_package_and_measure_worlds_never_share_a_slug(monkeypatch): + scores, _ = _staged( + monkeypatch, + [ + _row(fiscal_event="autumn_budget_2024", source_column="C2.A:Total"), + _row( + metric="supply_side_impact", + program="employer_nics", + subgroup="labour", + variant="tax", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event="autumn_budget_2024", + source_column="T2.1:Employer NICs", + description="", + ), + ], + ) + slugs = {s.reform.reform["policy"] for s in scores} + assert slugs == { + "obr_autumn_budget_2024_package", + "obr_autumn_budget_2024_employer_nics", + } + # NOT the null baseline: OBR measures both as deviations from the + # October 2024 round's pre-measures forecast, and every claim mirrors + # that world into conditions for queryability + for s in scores: + assert s.reform.baseline == { + "policy": "obr_pre_measures_autumn_budget_2024", + "counterfactual": "policy_parameters", + } + assert s.conditions["baseline_policy"] == ( + "obr_pre_measures_autumn_budget_2024" + ) + + +def test_same_measure_at_two_events_is_two_worlds(monkeypatch): + scores, _ = _staged( + monkeypatch, + [ + _row( + metric="supply_side_impact", + program="employee_nics_cut", + subgroup="labour", + variant="tax", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event=event, + source_column="T2.1:Employee NICs cut", + description="", + ) + for event in ("autumn_statement_2023", "spring_budget_2024") + ], + ) + assert len({s.reform.reform["policy"] for s in scores}) == 2 + assert len({s.claim_id() for s in scores}) == 2 + + +# --- relationships ---------------------------------------------------------- + + +def test_every_claim_is_held_out(monkeypatch): + """No pe-uk-data target or policyengine-uk parameter is fitted to a + macro-effect path: these are what the Macro members are scored + against.""" + scores, _ = _staged(monkeypatch, [_row()]) + assert all( + s.calibration_relationship is CalibrationRelationship.HELD_OUT for s in scores + ) + + +def test_unknown_basis_needs_a_deliberate_assignment(): + from scorecard_db.relationships import uk_relationship + + with pytest.raises(ValueError, match="deliberate"): + uk_relationship(SOURCE, Metric.GDP_LEVEL_EFFECT, program="x", kind="static") + + +# --- staged-file integration ------------------------------------------------ + +harvest_present = (EXTERNALS / "obr-policy-effects.json").exists() + + +@pytest.mark.skipif(not harvest_present, reason="harvest output not present (#75)") +def test_full_stage_accounting(): + """Exact accounting — a drifted harvest regeneration must fail here, + never grow or shrink the catalog silently.""" + scores, counts = stage() + assert counts == { + "gdp_level_effect": 151, + "cpi_inflation_effect": 36, + "supply_side_impact": 19, + "decisions_effect_on_borrowing": 60, + } + assert len(scores) == 266 + assert len({s.claim_id() for s in scores}) == 266 + + +@pytest.mark.skipif(not harvest_present, reason="harvest output not present (#75)") +def test_full_ingest_round_trip(tmp_path): + from scorecard_db.ingest_obr_policy_effects import ingest + + summary = ingest(tmp_path / "t.db") + assert summary["claims"] == 266 + db = ScorecardDB(tmp_path / "t.db") + n = db.conn.execute( + "SELECT COUNT(*) FROM external_scores WHERE source = ?", (SOURCE,) + ).fetchone()[0] + assert n == 266 + + # units: the percent families never land as GBP and vice versa + units = dict( + db.conn.execute( + "SELECT metric, unit_concept FROM external_scores" + " WHERE source = ? GROUP BY metric, unit_concept", + (SOURCE,), + ) + ) + assert units == { + Metric.GDP_LEVEL_EFFECT.value: UnitConcept.PERCENT_OF_REAL_GDP.value, + Metric.CPI_INFLATION_EFFECT.value: UnitConcept.PERCENTAGE_POINTS.value, + Metric.SUPPLY_SIDE_IMPACT.value: UnitConcept.PERCENT_OF_POTENTIAL_GDP.value, + Metric.DECISIONS_EFFECT_ON_BORROWING.value: UnitConcept.GBP.value, + } + # the three percent-shaped quantities never collapse into one label + assert UnitConcept.PERCENT.value not in units.values() + + # the double-count guard survives to the DB: every Table B.1 row + # carries its level, and the nested ones name their parent + levels = dict( + db.conn.execute( + "SELECT json_extract(conditions, '$.aggregate_level'), COUNT(*)" + " FROM external_scores WHERE source = ?" + " AND metric = 'decisions_effect_on_borrowing' GROUP BY 1", + (SOURCE,), + ) + ) + assert levels == {"total": 6, "subtotal": 24, "component": 30} + orphans = db.conn.execute( + "SELECT COUNT(*) FROM external_scores WHERE source = ?" + " AND metric = 'decisions_effect_on_borrowing'" + " AND json_extract(conditions, '$.aggregate_level') != 'total'" + " AND json_extract(conditions, '$.parent') IS NULL", + (SOURCE,), + ).fetchone()[0] + assert orphans == 0 + + # nothing may be consumed: these claims are scored, never fitted + consumed = db.conn.execute( + "SELECT COUNT(*) FROM external_scores WHERE source = ?" + " AND calibration_relationship != 'held_out'", + (SOURCE,), + ).fetchone()[0] + assert consumed == 0 + + # the two AB2024 charts stay distinguishable in the DB + ab2024_totals = db.conn.execute( + "SELECT DISTINCT json_extract(conditions, '$.decomposition')" + " FROM external_scores WHERE source = ?" + " AND json_extract(conditions, '$.fiscal_event') = 'autumn_budget_2024'", + (SOURCE,), + ).fetchall() + assert sorted(r[0] for r in ab2024_totals) == [ + "channel", + "expenditure_component", + "supply_side_channel", + ] + db.close() + + +@pytest.mark.skipif(not harvest_present, reason="harvest output not present (#75)") +def test_values_pass_through_verbatim(): + """Values arrive in raw units from the adapter and are never + re-derived here — spot-checked against the committed harvest.""" + raw = json.loads((EXTERNALS / "obr-policy-effects.json").read_text()) + scores, _ = stage() + # staging preserves adapter row order, so the pairing is positional — + # source_column alone is not unique (the same sheet label recurs + # across fiscal events) + assert len(scores) == len(raw) + for score, row in zip(scores, raw): + assert score.value == row["value"] + assert score.source_column == row["source_column"] + assert score.status == row["status"] + expected = ( + _BP10_HORIZON[row["fiscal_event"]][1] + if row["period"] == "forecast_horizon" + else _fy(row["period"])[0] + ) + assert score.period == expected + + +# --- units: validate, THEN map ---------------------------------------------- + + +def test_each_metric_carries_its_own_unit_concept(monkeypatch): + """Three quantities that all look like "percent" — a GDP-level + deviation, a CPI effect in percentage points, and an impact on + potential output — must never share one unit concept.""" + rows = [ + _row(), + _row( + metric="cpi_inflation_effect", + unit_concept="percentage_points", + subgroup="fuel_duty_freeze_extension", + source_column="C3.4:Fuel duty freeze extension", + aggregate_level="component", + parent="policy_package", + ), + _row( + metric="supply_side_impact", + program="employee_nics_cut", + subgroup="labour", + variant="tax", + period="forecast_horizon", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event="autumn_statement_2023", + source_column="T2.1:Employee NICs cut", + description="", + ), + ] + scores, _ = _staged(monkeypatch, rows) + assert [s.unit_concept for s in scores] == [ + UnitConcept.PERCENT_OF_REAL_GDP, + UnitConcept.PERCENTAGE_POINTS, + UnitConcept.PERCENT_OF_POTENTIAL_GDP, + ] + + +def test_staged_unit_is_validated_not_discarded(monkeypatch): + """The staged label used to be canon-checked and then thrown away, so + a GDP row mislabeled gbp_nominal staged as a percent. It must raise.""" + with pytest.raises(ValueError, match="staged unit"): + _staged(monkeypatch, [_row(unit_concept="gbp_nominal")]) + with pytest.raises(ValueError, match="staged unit"): + _staged( + monkeypatch, + [_row(metric="cpi_inflation_effect", unit_concept="percent_of_real_gdp")], + ) + # and bare "percent" is not even a registered label any more + with pytest.raises(ValueError, match="unregistered unit"): + _staged(monkeypatch, [_row(unit_concept="percent")]) + + +# --- publication provenance ------------------------------------------------- + + +def test_publication_is_per_artifact_with_the_release_date(): + """One generic publications/ URL dated to the newest round misdated + every earlier round's claims, and the March 2026 rows carried the + Wayback CAPTURE date rather than the publication date.""" + dates = {k: v["date"] for k, v in PUBLICATIONS.items()} + assert dates == { + "efo_november2023_chapter2.xlsx": "2023-11-22", + "efo_march2024_chapter2.xlsx": "2024-03-06", + "efo_october2024_chapter2.xlsx": "2024-10-30", + "efo_november2025_chapter3.xlsx": "2025-11-26", + "efo_march2026_annex_tables.xlsx": "2026-03-03", + "obr_briefing_paper_10_supply_side.xlsx": "2025-11-26", + } + assert "2026-03-16" not in dates.values() # the Wayback capture + for pub in PUBLICATIONS.values(): + assert pub["url"].endswith(".xlsx") # the dated artifact, not a hub page + assert pub["url"] != "https://obr.uk/publications/" + + +def test_unregistered_artifact_raises(monkeypatch): + with pytest.raises(ValueError, match="no publication registered"): + _staged(monkeypatch, [_row(artifact="efo_march2027_chapter9.xlsx")]) + + +def test_a_rows_publication_follows_its_own_round(monkeypatch): + scores, _ = _staged( + monkeypatch, + [ + _row( + fiscal_event="autumn_statement_2023", + artifact="efo_november2023_chapter2.xlsx", + source_column="C2.A:Total effect", + ), + _row(), + ], + ) + assert [s.publication["date"] for s in scores] == ["2023-11-22", "2025-11-26"] + + +# --- baseline worlds -------------------------------------------------------- + + +def test_baseline_must_match_the_rows_own_round(monkeypatch): + with pytest.raises(ValueError, match="not interchangeable|are scored against"): + _staged( + monkeypatch, + [ + _row( + fiscal_event="autumn_budget_2024", + source_column="C2.A:GDP", + baseline="obr_pre_measures_autumn_budget_2025", + ) + ], + ) + + +def test_baseline_without_a_locator_raises(monkeypatch): + with pytest.raises(ValueError, match="no locator"): + _staged(monkeypatch, [_row(baseline_locator=" ")]) + + +@pytest.mark.skipif(not harvest_present, reason="harvest output not present (#75)") +def test_no_claim_defaults_to_current_law(): + from scorecard_db.models import CURRENT_LAW_DESCRIPTOR, baseline_key + + scores, _ = stage() + null = baseline_key(CURRENT_LAW_DESCRIPTOR) + assert all(s.reform.baseline is not None for s in scores) + assert all(s.reform.baseline_key() != null for s in scores) + # seven rounds, and the counterfactual kind is part of the identity + worlds = {s.conditions["baseline_policy"] for s in scores} + assert len(worlds) == 7 + kinds = { + (s.reform.baseline["policy"], s.reform.baseline["counterfactual"]) + for s in scores + } + assert ("obr_pre_measures_spring_statement_2025", "regulatory") in kinds + assert ("obr_pre_measures_autumn_budget_2024", "del_activity") in kinds + + +@pytest.mark.skipif(not harvest_present, reason="harvest output not present (#75)") +def test_every_baseline_world_is_registered(tmp_path): + """The registration gate runs inside the claims' own transaction, so + an unregistered world rolls the whole replacement back. Assert it + passes for real — and that it is not passing by registering the null + world instead.""" + from scorecard_db.ingest_obr_policy_effects import ingest + + ingest(tmp_path / "t.db") + db = ScorecardDB(tmp_path / "t.db") + assert db.unregistered_baselines() == [] + labels = [ + r[0] + for r in db.conn.execute( + "SELECT DISTINCT b.label FROM external_scores s" + " JOIN baselines b ON b.baseline_key = s.baseline_key" + " WHERE s.source = ?", + (SOURCE,), + ).fetchall() + ] + assert "current_law" not in labels + assert len(labels) == 11 # the (round, counterfactual) pairs in the data + db.close() + + +# --- the build chain cannot silently lose a step --------------------------- + + +def test_every_imported_ingest_actually_runs(): + """build_db.py is one ordered list that many branches add to at the + same anchor, so it conflicts on nearly every merge — and the conflict + region splits a step tuple. A careless resolution either breaks the + syntax (loud) or DROPS A STEP (silent), shipping a database missing a + whole lane while every test still passes, because the tests that + would notice came with the dropped ingest. + + The import list is the contract: imported here means runs here. + """ + from scorecard_db import build_db + + steps = [ + ("x", (lambda: build_db.ingest_obr_policy_effects.ingest(None))), + ] + # a chain that imports more than it runs must be refused + with pytest.raises(SystemExit, match="but never runs them"): + build_db._assert_every_imported_ingest_runs(steps) + + +def test_the_real_chain_satisfies_the_contract(): + import inspect + + from scorecard_db import build_db + + src = inspect.getsource(build_db.build) + assert "_assert_every_imported_ingest_runs(steps)" in src + # and it passes for the chain as committed + tree = inspect.getsource(build_db) + assert "ingest_obr_policy_effects.ingest(db_path)" in tree