From 4f95e5c600021788dc185865c3d2b17a0af4fa5f Mon Sep 17 00:00:00 2001 From: r Date: Thu, 20 Aug 2026 15:37:31 +0100 Subject: [PATCH 1/3] Ingest OBR's published policy effects: 266 claims, four macro-effect metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harvest (#75) landed 266 rows in data/externals/obr-policy-effects.json that nothing consumed — invisible to the DB and the app. This wires them in as their own source (obr_policy_effects), registered in build_db's chain after uk_deductions. - Four new Metric members. gdp_level_effect (package effect on the real-GDP path) and supply_side_impact (one measure's effect on POTENTIAL output) are deliberately distinct, as is decisions_effect_on_borrowing (PSNB) from revenue_change and cash_requirement_change (PSNCR). - Reform worlds are policy_ref slugs — the fiscal event's package, or the measure for the supply-side family — against the null current_law baseline, per baselines.py's documented convention (announcement vintage is a condition, not a baseline world). No new baseline registered. - conditions["decomposition"], keyed off (fiscal_event, sheet): the October 2024 workbook prints the AB2024 package twice (2.A by expenditure component, 2.B by measure/channel), so both publish a 'total' and a 'demand_multipliers'. Without the axis the two totals collide on one claim_id — finish() caught it. The sheet id alone is not the identity: C2.A is by-channel in the Nov 2023 and Mar 2024 workbooks. - Briefing paper No.10 states its horizon in words ("the fifth year of our forecast"), never a digit. The note rides verbatim in conditions, horizon names it symbolically, and the year maps in one place (_BP10_HORIZON_FY) for a reviewer to re-key. - Table B.1's aggregate_level/parent guard survives into conditions, so summing borrowing effects by FY cannot double-count. All 266 are held_out (relationships.uk_relationship, never inline): no pe-uk-data target or policyengine-uk parameter is fitted to a macro-effect path. Derived-file changes are the build's own output, committed as built: data/lanes.json gains the lane entry and its updated date, mirrored to the app copy. Suite: 277 passed / 2 skipped (24 new); build deterministic across two runs; no-drift gate clean. Co-Authored-By: Claude Fable 5 --- app/public/data/lanes.json | 11 + data/lanes.json | 11 + scorecard_db/README.md | 51 +++ scorecard_db/build_db.py | 6 + scorecard_db/ingest_obr_policy_effects.py | 413 +++++++++++++++++++++ scorecard_db/models.py | 14 + scorecard_db/relationships.py | 22 ++ scorecard_db/uk_aliases.py | 85 +++++ tests/test_obr_policy_effects_ingest.py | 416 ++++++++++++++++++++++ 9 files changed, 1029 insertions(+) create mode 100644 scorecard_db/ingest_obr_policy_effects.py create mode 100644 tests/test_obr_policy_effects_ingest.py 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..b8db7e1 100644 --- a/scorecard_db/README.md +++ b/scorecard_db/README.md @@ -155,3 +155,54 @@ 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, per cent of +real GDP), `cpi_inflation_effect` (36), `supply_side_impact` (19, per +cent of POTENTIAL output) and `decisions_effect_on_borrowing` (60, GBP). +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). + +Every row carries a `policy_ref` reform naming the world scored — the +fiscal event's package, or the individual measure for the supply-side +family — against the null `current_law` baseline, since OBR scores an +announcement against the law in force at its own scoring date (the +convention `baselines.py` documents; no new baseline world is +registered). 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. + +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.** 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. The note rides verbatim in + `conditions["horizon_note"]`, `conditions["horizon"]` names it + symbolically, and the period maps to one module constant + (`_BP10_HORIZON_FY`) that a reviewer can re-key in one place. + +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/build_db.py b/scorecard_db/build_db.py index 2b77b7b..e026e34 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, @@ -89,6 +91,10 @@ 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", diff --git a/scorecard_db/ingest_obr_policy_effects.py b/scorecard_db/ingest_obr_policy_effects.py new file mode 100644 index 0000000..2159a66 --- /dev/null +++ b/scorecard_db/ingest_obr_policy_effects.py @@ -0,0 +1,413 @@ +"""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. The baseline stays the null ``current_law``: OBR +scores an announcement against the law in force at its own scoring date, +which is exactly the convention baselines.py documents (announcement +vintage is a condition — ``fiscal_event`` — not a distinct baseline +world), so this module registers no new baseline. + +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." That note is carried verbatim in +conditions["horizon_note"], with ``horizon`` naming it symbolically; the +period is _BP10_HORIZON_FY, the fifth year of the November 2025 forecast +the briefing paper accompanies. That mapping is the one interpretive +decision in this module — it is stated here, pinned by a test, and can +be re-keyed in one place if a reviewer reads the horizon differently. + +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 +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" + +PUBLICATIONS = { + "chart_data": { + "title": "OBR Economic and fiscal outlook: policy-effect chart data (Nov 2023, Mar 2024, Oct 2024, Nov 2025)", + "url": "https://obr.uk/publications/", + "date": "2025-11-26", + }, + "annex_b": { + "title": "OBR Economic and fiscal outlook March 2026, Annex B Table B.1: effect of Government decisions on borrowing", + "url": "https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/", + "date": "2026-03-16", + }, + "briefing_paper_10": { + "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 accompanies the November 2025 EFO, whose forecast +# runs 2025-26 (the current year) through 2030-31 — five forecast years, +# the fifth being 2030-31. The table names no digit, so the mapping is +# recorded here rather than inferred at each call site. +_BP10_HORIZON_NOTE = ( + "Supply-side impact is the impact on potential output in the fifth " + "year of our forecast." +) +_BP10_HORIZON_FY = "2030-31" +_BP10_HORIZON_PERIOD = 2031 + +# metric -> (Metric, UnitConcept, value_kind, publication key) +_METRICS = { + "gdp_level_effect": ( + Metric.GDP_LEVEL_EFFECT, + UnitConcept.PERCENT, + "percent", + "chart_data", + ), + "cpi_inflation_effect": ( + Metric.CPI_INFLATION_EFFECT, + UnitConcept.PERCENT, + "percent", + "chart_data", + ), + "supply_side_impact": ( + Metric.SUPPLY_SIDE_IMPACT, + UnitConcept.PERCENT, + "percent", + "briefing_paper_10", + ), + "decisions_effect_on_borrowing": ( + Metric.DECISIONS_EFFECT_ON_BORROWING, + UnitConcept.GBP, + "gbp", + "annex_b", + ), +} + +# 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", + "basis", + "country", + "description", + "fiscal_event", + "geography", + "metric", + "parent", + "period", + "program", + "scope", + "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", + } +) +_BASES = frozenset({"post_behavioural", "supply_side"}) + +# (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 _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"] + if row["scope"] == "package": + return policy_ref(f"obr_{event}_package") + return policy_ref(f"obr_{event}_{row['program']}") + + +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(): + canon(SOURCE, "unit", row["unit_concept"]) + metric, unit, value_kind, pub_key = _METRICS[row["metric"]] + 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"), + "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" + ) + period, fy = _BP10_HORIZON_PERIOD, _BP10_HORIZON_FY + cond["horizon"] = "fifth_year_of_forecast" + cond["horizon_note"] = _BP10_HORIZON_NOTE + else: + period, fy = _fy(row["period"]) + cond["fy"] = fy + scores.append( + ExternalScore( + source=SOURCE, + metric=metric, + unit_concept=unit, + period=period, + time_basis=TimeBasis.FISCAL_YEAR, + value=row["value"], + conditions=cond, + reform=_reform(row), + calibration_relationship=uk_relationship( + SOURCE, metric, program=cond["program"], kind=row["basis"] + )[0], + source_model="obr_efo", + source_column=row["source_column"], + publication=PUBLICATIONS[pub_key], + 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..33d9e0e 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): 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..a5ac878 100644 --- a/scorecard_db/uk_aliases.py +++ b/scorecard_db/uk_aliases.py @@ -298,6 +298,91 @@ 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"]) +_identity("obr_policy_effects", "unit", ["percent", "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 + "30_free_hours_of_childcare", + "employee_nics_cut", + "employer_nics", + "full_expensing", + "high_income_child_benefit_charge_hicbc", + "individual_placement_and_support_ips", + "pensions_allowances", + "public_investment", + "residential_planning_reforms", + "restart", + "talking_therapies", + "tax_thresholds", + "uc_conditionality", + "universal_credit_uc_childcare", + "universal_support", + "universal_support_extension", + "wca_reversal", + "work_capability_assessment_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_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..1b75c31 --- /dev/null +++ b/tests/test_obr_policy_effects_ingest.py @@ -0,0 +1,416 @@ +"""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_FY, + _BP10_HORIZON_PERIOD, + EXTERNALS, + 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", + "period": "2026-27", + "value": 0.26, + "status": "ok", + "fiscal_event": "autumn_budget_2025", + "basis": "post_behavioural", + "scope": "package", + "aggregate_level": "total", + "parent": None, + "source_column": "C3.3:Total", + } + base.update(over) + 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"), + ("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", + subgroup="labour", + variant="del", + period="forecast_horizon", + unit_concept="percent", + basis="supply_side", + scope="measure", + aggregate_level="component", + fiscal_event="spring_budget_2023", + source_column="T2.1:Restart", + description="Employment support", + ) + ], + ) + (s,) = scores + assert s.period == _BP10_HORIZON_PERIOD + assert s.conditions["fy"] == _BP10_HORIZON_FY + assert s.conditions["horizon"] == "fifth_year_of_forecast" + assert "fifth year of our forecast" in s.conditions["horizon_note"] + + +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", + basis="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", + } + # the null baseline: OBR scores an announcement against the law in + # force at its own scoring date (baselines.py convention), so this + # module registers no new baseline world + assert all(s.reform.baseline is None for s in scores) + + +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", + basis="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.value, + Metric.CPI_INFLATION_EFFECT.value: UnitConcept.PERCENT.value, + Metric.SUPPLY_SIDE_IMPACT.value: UnitConcept.PERCENT.value, + Metric.DECISIONS_EFFECT_ON_BORROWING.value: UnitConcept.GBP.value, + } + + # 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_PERIOD + if row["period"] == "forecast_horizon" + else _fy(row["period"])[0] + ) + assert score.period == expected From 50f1dea680ff6e3ce0924ff45ec1fddf6622d0f5 Mon Sep 17 00:00:00 2001 From: r Date: Fri, 21 Aug 2026 12:48:34 +0100 Subject: [PATCH 2/3] Address the five gate findings on the OBR policy-effects ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed as a pair with #75, which this branch now carries; the harvest side supplies the per-row provenance the fixes here consume. 1. Baselines are the rounds' own pre-measures worlds, not current_law. Every claim now carries a ReformRef.baseline descriptor — {policy: obr_pre_measures_, counterfactual: } — mirrored into conditions["baseline_policy"], with the counterfactual kind split as Briefing paper No.10 chapter 2 splits it (legislated parameters for tax/welfare, the pre-existing activity baseline for DEL, the regulatory baseline for planning). March 2026 Table B.1 keys the November 2025 Budget forecast, its own stated counterfactual. All eleven (round, counterfactual) worlds are registered in baselines.py, a row whose baseline disagrees with its own round raises, and a baseline without a locator raises. 2. BP10 horizons are per scoring round. The paper re-states scorings from five earlier events, so "the fifth year of our forecast" is each measure's own round's fifth year: 2027-28 (Mar 2023) through 2029-30 (Mar 2025). All 19 were keyed to 2030-31, the November 2025 round's horizon. Period is claim identity, so those were 19 claims about years OBR never scored them for. _BP10_HORIZON resolves per event and an unregistered round raises rather than borrowing another's year. 3. Unit concepts are deliberate, and validated before mapping. Three new UnitConcept members — PERCENT_OF_REAL_GDP, PERCENTAGE_POINTS, PERCENT_OF_POTENTIAL_GDP — replace the collapse into bare PERCENT, and the staged label is now compared against the metric's expected unit and raises on drift instead of being canon-checked then discarded. Bare "percent" is no longer a registered label for this source, so the three cannot collapse back. 4. Publication provenance is per artifact. Each round's claims carry their own release date and dated URL rather than one generic obr.uk/publications/ stamp dated 2025-11-26, and the 60 March-2026 claims carry the publication date (2026-03-03) instead of the Wayback capture (2026-03-16). An unregistered artifact raises. 5. `basis` stops squatting: it is `forecast` on every row (its repo-wide meaning) and the scoring method moved to its own `scoring_method` condition, which also keys the relationship lookup. 266 claims and the exact accounting unchanged; the DB builds deterministically (two builds agree on content_hash), suite 299 passed, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me --- scorecard_db/README.md | 71 ++++-- scorecard_db/baselines.py | 195 ++++++++++++++ scorecard_db/ingest_obr_policy_effects.py | 275 ++++++++++++++++---- scorecard_db/models.py | 50 ++++ scorecard_db/uk_aliases.py | 32 ++- tests/test_obr_policy_effects_ingest.py | 295 ++++++++++++++++++++-- 6 files changed, 823 insertions(+), 95 deletions(-) diff --git a/scorecard_db/README.md b/scorecard_db/README.md index b8db7e1..af1db75 100644 --- a/scorecard_db/README.md +++ b/scorecard_db/README.md @@ -169,22 +169,50 @@ before the campaign attaches). PYTHONPATH=. python -m scorecard_db.ingest_obr_policy_effects data/scorecard.db ``` -Four families on four new metrics — `gdp_level_effect` (151, per cent of -real GDP), `cpi_inflation_effect` (36), `supply_side_impact` (19, per -cent of POTENTIAL output) and `decisions_effect_on_borrowing` (60, GBP). -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 +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). -Every row carries a `policy_ref` reform naming the world scored — the -fiscal event's package, or the individual measure for the supply-side -family — against the null `current_law` baseline, since OBR scores an -announcement against the law in force at its own scoring date (the -convention `baselines.py` documents; no new baseline world is -registered). 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. +**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: @@ -195,12 +223,15 @@ Two identity decisions this population forced: 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.** 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. The note rides verbatim in - `conditions["horizon_note"]`, `conditions["horizon"]` names it - symbolically, and the period maps to one module constant - (`_BP10_HORIZON_FY`) that a reviewer can re-key in one place. +- **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 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/ingest_obr_policy_effects.py b/scorecard_db/ingest_obr_policy_effects.py index 2159a66..1a14852 100644 --- a/scorecard_db/ingest_obr_policy_effects.py +++ b/scorecard_db/ingest_obr_policy_effects.py @@ -27,11 +27,21 @@ 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. The baseline stays the null ``current_law``: OBR -scores an announcement against the law in force at its own scoring date, -which is exactly the convention baselines.py documents (announcement -vintage is a condition — ``fiscal_event`` — not a distinct baseline -world), so this module registers no new baseline. +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 @@ -43,12 +53,16 @@ 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." That note is carried verbatim in -conditions["horizon_note"], with ``horizon`` naming it symbolically; the -period is _BP10_HORIZON_FY, the fifth year of the November 2025 forecast -the briefing paper accompanies. That mapping is the one interpretive -decision in this module — it is stated here, pinned by a test, and can -be re-keyed in one place if a reviewer reads the horizon differently. +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 @@ -60,7 +74,7 @@ from pathlib import Path from .db import LANE_SQL, SCORES_SQL, ScorecardDB -from .harvest import REPO, finish, policy_ref, require_fields +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 @@ -82,62 +96,146 @@ # 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 = { - "chart_data": { - "title": "OBR Economic and fiscal outlook: policy-effect chart data (Nov 2023, Mar 2024, Oct 2024, Nov 2025)", - "url": "https://obr.uk/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", }, - "annex_b": { - "title": "OBR Economic and fiscal outlook March 2026, Annex B Table B.1: effect of Government decisions on borrowing", - "url": "https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/", - "date": "2026-03-16", + "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", }, - "briefing_paper_10": { - "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", + "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 accompanies the November 2025 EFO, whose forecast -# runs 2025-26 (the current year) through 2030-31 — five forecast years, -# the fifth being 2030-31. The table names no digit, so the mapping is -# recorded here rather than inferred at each call site. +# 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_FY = "2030-31" -_BP10_HORIZON_PERIOD = 2031 +_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, UnitConcept, value_kind, publication key) +# 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, - UnitConcept.PERCENT, + "percent_of_real_gdp", + UnitConcept.PERCENT_OF_REAL_GDP, "percent", - "chart_data", ), "cpi_inflation_effect": ( Metric.CPI_INFLATION_EFFECT, - UnitConcept.PERCENT, + "percentage_points", + UnitConcept.PERCENTAGE_POINTS, "percent", - "chart_data", ), "supply_side_impact": ( Metric.SUPPLY_SIDE_IMPACT, - UnitConcept.PERCENT, + "percent_of_potential_gdp", + UnitConcept.PERCENT_OF_POTENTIAL_GDP, "percent", - "briefing_paper_10", ), "decisions_effect_on_borrowing": ( Metric.DECISIONS_EFFECT_ON_BORROWING, + "gbp_nominal", UnitConcept.GBP, "gbp", - "annex_b", ), } @@ -147,6 +245,10 @@ _KNOWN_FIELDS = frozenset( { "aggregate_level", + "artifact", + "baseline", + "baseline_counterfactual", + "baseline_locator", "basis", "country", "description", @@ -157,6 +259,7 @@ "period", "program", "scope", + "scoring_method", "sign_convention", "source", "source_column", @@ -182,7 +285,30 @@ "march_2026_efo", } ) -_BASES = frozenset({"post_behavioural", "supply_side"}) +# `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 @@ -274,6 +400,36 @@ def _decomposition(row: dict) -> str: 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. @@ -283,9 +439,10 @@ def _reform(row: dict): 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") - return policy_ref(f"obr_{event}_{row['program']}") + 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]: @@ -293,14 +450,25 @@ def stage() -> tuple[list[ExternalScore], dict]: scores: list[ExternalScore] = [] counts: dict[str, int] = {} for row in _load(): - canon(SOURCE, "unit", row["unit_concept"]) - metric, unit, value_kind, pub_key = _METRICS[row["metric"]] + 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 @@ -328,12 +496,27 @@ def stage() -> tuple[list[ExternalScore], dict]: "only the briefing-paper supply-side table publishes a " "horizon-terminal number" ) - period, fy = _BP10_HORIZON_PERIOD, _BP10_HORIZON_FY - cond["horizon"] = "fifth_year_of_forecast" + 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, @@ -343,13 +526,13 @@ def stage() -> tuple[list[ExternalScore], dict]: time_basis=TimeBasis.FISCAL_YEAR, value=row["value"], conditions=cond, - reform=_reform(row), + reform=reform, calibration_relationship=uk_relationship( - SOURCE, metric, program=cond["program"], kind=row["basis"] + SOURCE, metric, program=cond["program"], kind=row["scoring_method"] )[0], source_model="obr_efo", source_column=row["source_column"], - publication=PUBLICATIONS[pub_key], + publication=PUBLICATIONS[row["artifact"]], value_kind=value_kind, status=row["status"], ) diff --git a/scorecard_db/models.py b/scorecard_db/models.py index 33d9e0e..9cdfa6c 100644 --- a/scorecard_db/models.py +++ b/scorecard_db/models.py @@ -146,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). @@ -256,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/uk_aliases.py b/scorecard_db/uk_aliases.py index a5ac878..8785167 100644 --- a/scorecard_db/uk_aliases.py +++ b/scorecard_db/uk_aliases.py @@ -307,7 +307,21 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: # spending line, so unifying the two namespaces would be a category # error. _identity("obr_policy_effects", "geography", ["UK"]) -_identity("obr_policy_effects", "unit", ["percent", "gbp_nominal"]) +# 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", @@ -327,24 +341,24 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: "pillar_2_reforms", "other_tax_measures", # briefing paper No.10 T2.1: individually scored measures - "30_free_hours_of_childcare", + "free_childcare_30_hours", "employee_nics_cut", "employer_nics", "full_expensing", - "high_income_child_benefit_charge_hicbc", - "individual_placement_and_support_ips", + "hicbc_threshold", + "individual_placement_and_support", "pensions_allowances", "public_investment", "residential_planning_reforms", - "restart", + "restart_scheme", "talking_therapies", - "tax_thresholds", + "tax_threshold_freeze", "uc_conditionality", - "universal_credit_uc_childcare", + "uc_childcare_upfront_costs", "universal_support", "universal_support_extension", "wca_reversal", - "work_capability_assessment_wca_reforms", + "wca_reforms", ], ) _identity( @@ -374,7 +388,7 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: "supply_full_expensing", "supply_nics_cut", "supply_public_investment", - "supply_welfare_reforms_other", + "supply_welfare_reforms_and_other", # CPI-impact measures (Nov 2025 C3.4) "energy_bills_package", "fuel_duty_freeze_extension", diff --git a/tests/test_obr_policy_effects_ingest.py b/tests/test_obr_policy_effects_ingest.py index 1b75c31..440fa97 100644 --- a/tests/test_obr_policy_effects_ingest.py +++ b/tests/test_obr_policy_effects_ingest.py @@ -11,9 +11,10 @@ from scorecard_db import Metric, ScorecardDB, UnitConcept from scorecard_db.ingest_obr_policy_effects import ( - _BP10_HORIZON_FY, - _BP10_HORIZON_PERIOD, + _BP10_HORIZON, + _EVENT_BASELINE, EXTERNALS, + PUBLICATIONS, SOURCE, _decomposition, _fy, @@ -31,18 +32,32 @@ def _row(**over): "subgroup": "total", "variant": None, "geography": "UK", - "unit_concept": "percent", + "unit_concept": "percent_of_real_gdp", "period": "2026-27", "value": 0.26, "status": "ok", "fiscal_event": "autumn_budget_2025", - "basis": "post_behavioural", + "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 @@ -90,6 +105,9 @@ def test_unknown_metric_raises(monkeypatch): [ ("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"), ], @@ -198,12 +216,12 @@ def test_supply_side_horizon_is_named_not_guessed(monkeypatch): [ _row( metric="supply_side_impact", - program="restart", + program="restart_scheme", subgroup="labour", variant="del", period="forecast_horizon", - unit_concept="percent", - basis="supply_side", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", scope="measure", aggregate_level="component", fiscal_event="spring_budget_2023", @@ -213,12 +231,75 @@ def test_supply_side_horizon_is_named_not_guessed(monkeypatch): ], ) (s,) = scores - assert s.period == _BP10_HORIZON_PERIOD - assert s.conditions["fy"] == _BP10_HORIZON_FY - assert s.conditions["horizon"] == "fifth_year_of_forecast" + 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.""" @@ -240,7 +321,8 @@ def test_package_and_measure_worlds_never_share_a_slug(monkeypatch): subgroup="labour", variant="tax", period="forecast_horizon", - basis="supply_side", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", scope="measure", aggregate_level="component", fiscal_event="autumn_budget_2024", @@ -254,10 +336,17 @@ def test_package_and_measure_worlds_never_share_a_slug(monkeypatch): "obr_autumn_budget_2024_package", "obr_autumn_budget_2024_employer_nics", } - # the null baseline: OBR scores an announcement against the law in - # force at its own scoring date (baselines.py convention), so this - # module registers no new baseline world - assert all(s.reform.baseline is None for s in scores) + # 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): @@ -270,7 +359,8 @@ def test_same_measure_at_two_events_is_two_worlds(monkeypatch): subgroup="labour", variant="tax", period="forecast_horizon", - basis="supply_side", + unit_concept="percent_of_potential_gdp", + scoring_method="supply_side", scope="measure", aggregate_level="component", fiscal_event=event, @@ -345,11 +435,13 @@ def test_full_ingest_round_trip(tmp_path): ) ) assert units == { - Metric.GDP_LEVEL_EFFECT.value: UnitConcept.PERCENT.value, - Metric.CPI_INFLATION_EFFECT.value: UnitConcept.PERCENT.value, - Metric.SUPPLY_SIDE_IMPACT.value: UnitConcept.PERCENT.value, + 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 @@ -409,8 +501,171 @@ def test_values_pass_through_verbatim(): assert score.source_column == row["source_column"] assert score.status == row["status"] expected = ( - _BP10_HORIZON_PERIOD + _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() From b05fed68340521108318d40604e94fa0541316dc Mon Sep 17 00:00:00 2001 From: r Date: Tue, 25 Aug 2026 10:03:30 +0100 Subject: [PATCH 3/3] Refuse a build chain that imports an ingest it never runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in an integration review pass. Six of the open UK branches insert a build step at the same anchor in build_db.py, so it conflicts on nearly every merge — and the conflict region SPLITS A STEP TUPLE, which I confirmed by simulating the merge queue: a naive both-sides union produces invalid Python. That is the good case. The bad case is a resolution that drops a step, which ships a database missing an entire 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 now the contract: a module imported at the top of build_db must appear in the chain. build() checks it before running anything, and the failure names the merge-resolution cause rather than just the symptom. Verified by deleting a step and watching it refuse. This is a small guard on a real hazard — with twelve UK branches in flight against one ordered list, the queue will hit this conflict repeatedly, and 'resolve by taking both sides' is exactly what a hurried resolver does. Suite 301 passed, two builds agree on content_hash, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me --- scorecard_db/build_db.py | 40 +++++++++++++++++++++++++ tests/test_obr_policy_effects_ingest.py | 35 ++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/scorecard_db/build_db.py b/scorecard_db/build_db.py index e026e34..c1a7eb8 100644 --- a/scorecard_db/build_db.py +++ b/scorecard_db/build_db.py @@ -49,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.""" @@ -101,6 +140,7 @@ def build(db_path: Path) -> dict: 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/tests/test_obr_policy_effects_ingest.py b/tests/test_obr_policy_effects_ingest.py index 440fa97..160e65c 100644 --- a/tests/test_obr_policy_effects_ingest.py +++ b/tests/test_obr_policy_effects_ingest.py @@ -669,3 +669,38 @@ def test_every_baseline_world_is_registered(tmp_path): 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