diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1aa96..c7a7387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,10 @@ jobs: # ingest — the tree must equal HEAD afterwards, or a committed # artifact has gone stale relative to the code that regenerates it. - run: git diff --exit-code - - run: uv run --with pytest pytest tests/ -q + # pypdf + pyyaml are NOT optional extras: the HMT lane's strongest + # honesty guarantee (every registry title and figure anchor verbatim + # in the real PDF) runs as a test, and a bare env silently skipped it. + - run: uv run --with pytest --with pypdf --with pyyaml pytest tests/ -q env: PYTHONPATH: . - run: git diff --exit-code diff --git a/app/public/data/lanes.json b/app/public/data/lanes.json index d2b4a03..ee387c1 100644 --- a/app/public/data/lanes.json +++ b/app/public/data/lanes.json @@ -1,7 +1,7 @@ { "_schema": { "stage": "registered | cataloged | ingested | computed | diagnosing | published | regressed", - "country": "US | UK | BE \u2014 the model instance the lane feeds (issue #42)", + "country": "US | UK \u2014 the model instance the lane feeds (issue #42)", "note": "Repo-committed status feed (issue #7); graduate to Supabase when lane count makes commits noisy. Every lane writes its transition here." }, "updated": "2026-08-21", @@ -347,6 +347,17 @@ "updated": "2026-08-19", "note": "7 claims" }, + { + "id": "hmt-distributional", + "source": "HM Treasury", + "area": "Budget distributional analysis (chart-only)", + "mode": 2, + "country": "UK", + "stage": "cataloged", + "running": false, + "updated": "2026-08-17", + "note": "0 claims \u2014 chart-only publication; 132 source marks = 0 emitted + 132 chart_not_digitized (11 income groups x 4 series x 3 figures)" + }, { "id": "jrc-euromod-be-country-report", "source": "JRC EUROMOD", diff --git a/data/externals/hmt-distributional-meta.json b/data/externals/hmt-distributional-meta.json new file mode 100644 index 0000000..b820c03 --- /dev/null +++ b/data/externals/hmt-distributional-meta.json @@ -0,0 +1,880 @@ +{ + "source": "hmt-distributional", + "package_key": "budget_2025__impact_on_households", + "raw_sha256": "9f7e68f3e44349c8a6a4a89b9e1427cc23f1691771902b154ef67cadd33480ea", + "figures_verified": [ + "1.A", + "1.B", + "1.C" + ], + "components": 30, + "component_counts": { + "by_computability": { + "expressible": 3, + "partial": 9, + "not_expressible": 18 + }, + "by_channel": { + "tax": 16, + "welfare": 9, + "benefits_in_kind_public_services": 5 + } + }, + "value_claims_emitted": 0, + "chart_cells": { + "source_marks": 132, + "emitted": 0, + "chart_not_digitized": 132, + "by_figure": { + "1.A": 44, + "1.B": 44, + "1.C": 44 + }, + "income_groups": [ + "decile_1", + "decile_2", + "decile_3", + "decile_4", + "decile_5", + "decile_6", + "decile_7", + "decile_8", + "decile_9", + "decile_10", + "all_households" + ], + "series": [ + "tax", + "welfare", + "benefits_in_kind_public_services", + "overall" + ], + "cells": [ + { + "figure": "1.A", + "income_group": "decile_1", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_1", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_1", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_1", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_2", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_2", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_2", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_2", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_3", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_3", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_3", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_3", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_4", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_4", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_4", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_4", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_5", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_5", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_5", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_5", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_6", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_6", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_6", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_6", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_7", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_7", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_7", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_7", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_8", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_8", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_8", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_8", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_9", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_9", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_9", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_9", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_10", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_10", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_10", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "decile_10", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "all_households", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "all_households", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "all_households", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.A", + "income_group": "all_households", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_1", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_1", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_1", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_1", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_2", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_2", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_2", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_2", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_3", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_3", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_3", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_3", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_4", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_4", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_4", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_4", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_5", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_5", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_5", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_5", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_6", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_6", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_6", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_6", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_7", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_7", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_7", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_7", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_8", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_8", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_8", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_8", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_9", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_9", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_9", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_9", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_10", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_10", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_10", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "decile_10", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "all_households", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "all_households", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "all_households", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.B", + "income_group": "all_households", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_1", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_1", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_1", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_1", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_2", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_2", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_2", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_2", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_3", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_3", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_3", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_3", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_4", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_4", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_4", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_4", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_5", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_5", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_5", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_5", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_6", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_6", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_6", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_6", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_7", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_7", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_7", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_7", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_8", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_8", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_8", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_8", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_9", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_9", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_9", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_9", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_10", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_10", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_10", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "decile_10", + "series": "overall", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "all_households", + "series": "tax", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "all_households", + "series": "welfare", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "all_households", + "series": "benefits_in_kind_public_services", + "disposition": "chart_not_digitized" + }, + { + "figure": "1.C", + "income_group": "all_households", + "series": "overall", + "disposition": "chart_not_digitized" + } + ] + }, + "income_identity": { + "income_axis": "equivalised_net_household_income_decile", + "housing_costs": "bhc", + "equivalisation": "modified_oecd", + "income_groups": [ + "decile_1", + "decile_2", + "decile_3", + "decile_4", + "decile_5", + "decile_6", + "decile_7", + "decile_8", + "decile_9", + "decile_10", + "all_households" + ] + }, + "chart_baselines": { + "1.A": { + "kind": "change", + "baseline": "hmt_no_policy_change_from_ab2024" + }, + "1.B": { + "kind": "change", + "baseline": "hmt_no_policy_change_from_ab2024" + }, + "1.C": { + "kind": "level", + "baseline": "current_law" + } + }, + "value_availability": "HMT publishes decile impacts as unlabeled chart bars in a PDF (see sources/hmt-distributional/raw/README.md). Entries therefore carry chart inventory, not values; external value claims enter only if HMT releases data tables or a documented chart digitization lands with per-bar provenance. Nothing downstream may treat this registry as a value source." +} diff --git a/data/lanes.json b/data/lanes.json index d2b4a03..ee387c1 100644 --- a/data/lanes.json +++ b/data/lanes.json @@ -1,7 +1,7 @@ { "_schema": { "stage": "registered | cataloged | ingested | computed | diagnosing | published | regressed", - "country": "US | UK | BE \u2014 the model instance the lane feeds (issue #42)", + "country": "US | UK \u2014 the model instance the lane feeds (issue #42)", "note": "Repo-committed status feed (issue #7); graduate to Supabase when lane count makes commits noisy. Every lane writes its transition here." }, "updated": "2026-08-21", @@ -347,6 +347,17 @@ "updated": "2026-08-19", "note": "7 claims" }, + { + "id": "hmt-distributional", + "source": "HM Treasury", + "area": "Budget distributional analysis (chart-only)", + "mode": 2, + "country": "UK", + "stage": "cataloged", + "running": false, + "updated": "2026-08-17", + "note": "0 claims \u2014 chart-only publication; 132 source marks = 0 emitted + 132 chart_not_digitized (11 income groups x 4 series x 3 figures)" + }, { "id": "jrc-euromod-be-country-report", "source": "JRC EUROMOD", diff --git a/data/uk/hmt_da_packages.yaml b/data/uk/hmt_da_packages.yaml new file mode 100644 index 0000000..42c7318 --- /dev/null +++ b/data/uk/hmt_da_packages.yaml @@ -0,0 +1,202 @@ +schema_version: 1 +benchmark_class: different_model +lane: hmt_distributional_analysis +registry_rule: >- + One entry per fiscal-event edition of HM Treasury's "Impact on households" + distributional analysis. An entry records the package the edition scores + (composition semantics, verbatim measure titles from the publication's + in-scope list), the published counterfactual and income concept, and an + honest computability triage per component. It never invents reform + parameter dicts: PE-expressible components reference measure families for + the obr_measure_reforms.yaml registry (data/uk/, #54) to carry the + executable spec, so one measure has one executable home. +value_availability_rule: >- + HMT publishes decile impacts as unlabeled chart bars in a PDF (see + sources/hmt-distributional/raw/README.md). Entries therefore carry chart + inventory, not values; external value claims enter only if HMT releases + data tables or a documented chart digitization lands with per-bar + provenance. Nothing downstream may treat this registry as a value source. + +packages: + - package_key: budget_2025__impact_on_households + fiscal_event: Budget 2025 + published: 2025-11-26 + document: sources/hmt-distributional/raw/Impact_on_households.pdf + sha256: 9f7e68f3e44349c8a6a4a89b9e1427cc23f1691771902b154ef67cadd33480ea + horizon: "2028-29" + counterfactual: >- + No policy changes over the relevant period: cumulative impact of + decisions announced from Autumn Budget 2024 onwards, including Spring + Statement 2025, Spending Review 2025 and Budget 2025 (para 1.1, 1.6). + income_concept: equivalised net household income, before housing costs + ranking: income deciles of the household distribution in 2028-29 + # The decile groups every figure plots, as CLOSED identities rather + # than prose. Ten equivalised-net-income deciles plus the all- + # households summary bar: 11 groups x 4 series x 3 figures = the 132 + # chart cells the omission tally must account for. + income_groups: + [ + decile_1, decile_2, decile_3, decile_4, decile_5, + decile_6, decile_7, decile_8, decile_9, decile_10, + all_households, + ] + housing_costs: bhc + equivalisation: modified_oecd + charts: + # baseline: which world each figure is measured against. 1.A and + # 1.B are CHANGES against the no-policy-change package world; 1.C + # is a post-policy LEVEL under current law. One prose counterfactual + # covering all three would let future rows default to current_law. + - figure: "1.A" + metric: pct_net_income_change + split: [tax, welfare, benefits_in_kind_public_services, overall] + kind: change + baseline: hmt_no_policy_change_from_ab2024 + - figure: "1.B" + metric: cash_change_gbp_per_year + split: [tax, welfare, benefits_in_kind_public_services, overall] + kind: change + baseline: hmt_no_policy_change_from_ab2024 + - figure: "1.C" + metric: overall_level_pct_net_income + split: [tax, welfare, benefits_in_kind_public_services, overall] + kind: level + baseline: current_law + published_exclusions: + # para 1.2-1.3 and 2.3: axes to name before any engine defect is + # inferred from a divergence. + - business taxes (incl. employer NICs incidence) + - regulation (incl. National Living Wage) + - government borrowing + - non-UK residents + - behavioural impacts of most measures + - fraud/error/compliance effects (full compliance assumed) + - devolved administrations' own decisions + - measures without a direct impact in 2028-29 + - supply-side growth policies' indirect effects + components: + # Verbatim Budget 2025 measure titles from the in-scope list + # (para 1.4, pp. 7-9), triaged for PE-UK expressibility. "partial" + # names what is missing; measure_family points at the executable + # registry lane where one exists or should exist. + - title: "Personal Tax: Maintain the personal income tax and equivalent national insurance thresholds at current levels for a further three years until April 2031" + channel: tax + computability: expressible + measure_family: pa_and_hrt_freezes + - title: "Dividend Income: Increase tax rates on dividend income by 2ppts at the ordinary and upper rate from 6 April 2026" + channel: tax + computability: expressible + measure_family: dividend_rates + - title: "Savings Income: Increase tax rates on savings income by 2ppts at the basic, higher and additional rate from 6 April 2027 and maintain the Starting Rate of Savings limit at £5000 from April 2026 to April 2031" + channel: tax + computability: partial + missing: starting-rate-limit interaction modeled; savings-rate uplift expressible; ISA composition shifts (behavioural) out of scope by construction + measure_family: savings_rates + - title: "Property Income: Introduce separate tax rates for property income at 22% for the property basic rate, 42% for the property higher rate and 47% for the property additional rate, from 6 April 2027" + channel: tax + computability: partial + missing: separate property-income rate schedule not in the pe-uk parameter tree + - title: "Universal Credit Child Element: Remove the two child limit from 1 April 2026, taking 450,000 children out of poverty" + channel: welfare + computability: expressible + measure_family: uc_child_element_remove_two_child_limit + - title: "Winter Fuel Payment: Target to pensioners with taxable income below or equal to £35,000 from Winter 2025" + channel: welfare + computability: partial + missing: income-targeted clawback mechanism vs pe-uk winter fuel modeling to be verified + - title: "Universal Credit: Changes to the standard allowance and health element to protect existing claimants and new health element claimants who meet the Severe Conditions Criteria from April 2026" + channel: welfare + computability: partial + missing: existing-claimant protection requires claim-history state the certified bundle does not carry + - title: "Personal Independence Payment: Not proceeding with Spring Statement 2025 reforms to eligibility" + channel: welfare + computability: partial + missing: scored relative to a counterfactual containing the abandoned reform; needs the announcement-baseline leg (#13) + - title: "Child Benefit: Exempt 16–19-year-olds with an illness or disability from the 12-hour weekly rule and equalise the treatment of non-standard educational settings from 1 September 2025" + channel: welfare + computability: not_expressible + why: eligibility-administration detail below the resolution of the survey data + - title: "Health and Disability Benefits: Improve operations by increasing face-to-face assessments, increasing WCA reassessment capability, and PIP award review changes, starting from April 2026" + channel: welfare + computability: not_expressible + why: operational-delivery measure, no parametric representation + - title: "Housing Benefit and Pension Credit Administration: Bring together the administration of pensioner Housing Benefit and Pension Credit from Autumn 2026" + channel: welfare + computability: not_expressible + why: administrative consolidation, no parametric representation + - title: "Renewables Obligation: Fund 75% of the domestic share of the Renewables Obligation via the Exchequer over the Spending Review period" + channel: welfare + computability: not_expressible + why: bill-reduction incidence via energy levies, outside the tax-benefit parameter tree (HMT itself books it under "Welfare (including Renewables Obligation)") + - title: "Fuel Duty: Cancel uprating for 2026-27; extend the 5p cut in rates to 31 August 2026, then increase by 1p from 1 September 2026, 2p from 1 December 2026, and 2p from 1 March 2027" + channel: tax + computability: partial + missing: consumption incidence requires expenditure imputation; pe-uk carries fuel duty via consumption module only + - title: "High Value Council Tax Surcharge: Introduce a surcharge on owners of residential properties valued over £2m in England from 1 April 2028" + channel: tax + computability: partial + missing: property-value distribution above £2m not resolved in the certified bundle + - title: "Student Loans: Freeze Plan 2 repayment threshold for 3 years from 6 April 2027" + channel: tax + computability: partial + missing: student loan repayments modeled; plan-type composition to be verified + - title: "Motability: Introduce VAT at the standard rate on Advance Payments paid to Motability or equivalent schemes, and Insurance Premium Tax at the standard rate on insurance related to vehicle leases, from 1 July 2026" + channel: tax + computability: not_expressible + why: scheme-specific indirect tax incidence + - title: "Supporting Savers: Make the Help to Save scheme permanent and maintain total ISA annual subscription limit at £20,000 with cash limit reduced to £12,000 for under-65s from April 2027" + channel: tax + computability: not_expressible + why: savings-vehicle composition response, no first-order tax-benefit delta on the survey data + - title: "Higher Education Maintenance Grant Offer: Targeted means-tested maintenance grants for students from low-income households studying courses aligned with our missions and Industrial Strategy" + channel: welfare + computability: not_expressible + why: course-aligned eligibility below survey resolution + - title: "Rail Fares: Freeze rail fares in England for one year from 1 March 2026" + channel: benefits_in_kind_public_services + computability: not_expressible + why: public-services benefits-in-kind allocation, outside PE scope + - title: "Health and Social Care: Freeze NHS prescription charges in England for one year from 1 April 2026" + channel: benefits_in_kind_public_services + computability: not_expressible + why: public-services benefits-in-kind allocation, outside PE scope + - title: "Jobs and Skills: Invest in Youth Guarantee, Growth and Skills Levy, and employment & skills support" + channel: benefits_in_kind_public_services + computability: not_expressible + why: public-services spending allocation, outside PE scope + - title: "RDEL: Go further on efficiencies and savings" + channel: benefits_in_kind_public_services + computability: not_expressible + why: public-services spending allocation, outside PE scope + - title: "Tour Operators' Margin Scheme: Exclude Private Hire Vehicle Services from the scope of the scheme from 2 January 2026" + channel: tax + computability: not_expressible + why: indirect tax incidence on a service niche + - title: "Low Value Imports: Reform the customs treatment of low value imports from March 2029" + channel: tax + computability: not_expressible + why: customs incidence, outside the tax-benefit parameter tree + - title: "Non-reimbursed Homeworking Expenses: Remove the tax relief available from 6 April 2026" + channel: tax + computability: partial + missing: homeworking-relief claimant identification not in the survey data + - title: "Electric Vehicle Excise Duty (eVED): Introduce a new mileage supplement for electric and plug-in hybrid cars from 1 April 2028" + channel: tax + computability: not_expressible + why: mileage-based duty, no vehicle-use data on the bundle + - title: "Support for British automotive industry: Provide additional Electric Car Grant funding & extend to 2029-30" + channel: benefits_in_kind_public_services + computability: not_expressible + why: grant scheme outside the tax-benefit parameter tree + - title: "Support for British automotive industry: Increase the Expensive Car Supplement threshold for zero emission vehicles to £50,000, from 1 April 2026" + channel: tax + computability: not_expressible + why: vehicle excise composition, no vehicle data on the bundle + - title: "Plug-in hybrid electric vehicle (PHEV) emissions regulatory standard changes and benefit in kind easement" + channel: tax + computability: not_expressible + why: benefit-in-kind niche without company-car composition data + - title: "Council Tax Precepts: £5 flexibility for fire authorities between 2026-27 & 2028-29" + channel: tax + computability: not_expressible + why: local-authority precept decisions, not a parametric national change diff --git a/scorecard_db/baselines.py b/scorecard_db/baselines.py index c82c412..03ab953 100644 --- a/scorecard_db/baselines.py +++ b/scorecard_db/baselines.py @@ -198,6 +198,24 @@ "with two-child limit and family premium removal fully rolled " "out' (sources/harvest-uk-2026-08-02/uk_ifs).", ), + ( + {"policy": "hmt_no_policy_change_from_ab2024"}, + "hmt_no_policy_change_from_ab2024", + "HM Treasury's published counterfactual for the Budget 2025 " + "'Impact on households' analysis: no policy changes over the " + "relevant period — i.e. the world without any of the decisions " + "announced from Autumn Budget 2024 onwards (Spring Statement " + "2025, Spending Review 2025 and Budget 2025 included), scored in " + "2028-29. Figures 1.A and 1.B are CHANGES against this world; " + "figure 1.C is a post-policy LEVEL and keys current law instead, " + "so the three charts are deliberately not one world.", + "policy_ref", + "Impact on households (Budget 2025) paras 1.1 and 1.6, vendored at " + "sources/hmt-distributional/raw/Impact_on_households.pdf (sha256 " + "in data/uk/hmt_da_packages.yaml); per-figure assignment lives in " + "that registry's charts[].baseline and is emitted into " + "data/externals/hmt-distributional-meta.json.", + ), ( {"policy": "pre_ab2025"}, "pre_ab2025", diff --git a/scorecard_db/build_db.py b/scorecard_db/build_db.py index f37be52..b3ed6d2 100644 --- a/scorecard_db/build_db.py +++ b/scorecard_db/build_db.py @@ -19,6 +19,8 @@ campaign_us staged day-1/day-2 campaign results (claim matching) uk_externals five UK primary-source families + Ledger staging uk_deductions FRR family + hmt_distributional HMT Budget distributional analysis (zero claims, + declared: 132 chart cells, none digitized) produce_uk + campaign_uk archive-resolved UK reckoner attaches be_jrc JRC EUROMOD-BE model claims + honest demo attachments; final so its 2026-08-21 lane update cannot be regressed by @@ -42,6 +44,7 @@ ingest_campaign, ingest_diagnoses, ingest_harvest, + ingest_hmt_distributional, ingest_platform, ingest_reform_validation, ingest_solo, @@ -94,6 +97,14 @@ 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)), + # A ZERO-claim lane, registered on purpose: HMT publishes the + # Budget decile impacts as unlabeled chart bars, and without this + # step a fresh build contained no trace of the lane at all — + # indistinguishable from never having harvested it. + ( + "hmt_distributional", + lambda: ingest_hmt_distributional.ingest(db_path), + ), ("produce_uk", lambda: produce_campaign_uk.produce(db_path)), ( "campaign_uk", diff --git a/scorecard_db/ingest_hmt_distributional.py b/scorecard_db/ingest_hmt_distributional.py new file mode 100644 index 0000000..5efccba --- /dev/null +++ b/scorecard_db/ingest_hmt_distributional.py @@ -0,0 +1,102 @@ +"""Register the HMT distributional-analysis lane in the built DB (#61). + +The lane emits ZERO value claims and says so: HM Treasury publishes the +Budget 2025 decile impacts as unlabeled chart bars in a PDF, with no data +tables (sources/hmt-distributional/adapter.py explains the whole call). +That honesty was the point of the lane — but it was also why the lane +VANISHED from a post-#74 build: the adapter wrote standalone metadata, +nothing registered in build_db.py, and no lane row existed, so a fresh +CI-built database contained no trace of HMT at all. Not even a zero. + +This module is the missing step. It writes the lane row and mirrors it +into the committed feed, carrying the adapter's exact chart-cell +accounting as the lane detail: + + 132 source marks = 0 emitted + 132 chart_not_digitized + +so a reader of the DB (or mission control) sees a declared, quantified +omission rather than an absence. It deliberately writes no +external_scores rows; when HMT releases data tables or a documented +digitization lands, THIS is where the ingest goes, and the identity and +baseline machinery it will need is already carried in the metadata +artifact (income_identity, chart_baselines) and registered in +baselines.py / uk_aliases.py. + + PYTHONPATH=. python -m scorecard_db.ingest_hmt_distributional data/scorecard.db +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .db import LANE_SQL, ScorecardDB +from .harvest import REPO + +META = REPO / "data" / "externals" / "hmt-distributional-meta.json" + +LANE_ID = "hmt-distributional" +LANE_UPDATED = "2026-08-17" +# The UK family's shared top-level feed literal (sync_lane_feed's +# contract: every caller in a build must pass the same one). +FEED_UPDATED = "2026-08-19" +LANE_FEED_META = { + "source": "HM Treasury", + "area": "Budget distributional analysis (chart-only)", + "mode": 2, + "country": "UK", +} + + +def ingest(db_path: Path) -> dict: + if not META.exists(): + raise FileNotFoundError( + f"{META} missing — run sources/hmt-distributional/adapter.py " + "(needs pypdf + pyyaml)" + ) + meta = json.loads(META.read_text()) + if meta["value_claims_emitted"] != 0: + raise ValueError( + "hmt-distributional is a chart-only lane: a metadata artifact " + "claiming emitted values means the digitization landed without " + "an ingest path for it" + ) + cells = meta["chart_cells"] + if cells["emitted"] + cells["chart_not_digitized"] != cells["source_marks"]: + raise ValueError( + f"chart-cell accounting does not close: {cells['emitted']} + " + f"{cells['chart_not_digitized']} != {cells['source_marks']}" + ) + detail = ( + f"0 claims — chart-only publication; {cells['source_marks']} source " + f"marks = {cells['emitted']} emitted + " + f"{cells['chart_not_digitized']} chart_not_digitized " + f"({len(meta['income_identity']['income_groups'])} income groups x " + f"{len(cells['series'])} series x {len(cells['by_figure'])} figures)" + ) + + from .ingest_harvest import sync_lane_feed + + db = ScorecardDB(db_path) + with db.conn: + db.conn.execute(LANE_SQL, (LANE_ID, "cataloged", detail, LANE_UPDATED)) + sync_lane_feed( + db, + REPO / "data" / "lanes.json", + FEED_UPDATED, + lanes={LANE_ID: LANE_FEED_META}, + ) + db.close() + return { + "claims": 0, + "source_marks": cells["source_marks"], + "chart_not_digitized": cells["chart_not_digitized"], + "components": meta["components"], + } + + +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/relationships.py b/scorecard_db/relationships.py index 31c3d7f..dfd04c8 100644 --- a/scorecard_db/relationships.py +++ b/scorecard_db/relationships.py @@ -222,6 +222,24 @@ def effective_relationship(program, metric): "nothing in pe-uk-data consumes them.", ) +# HMT's Budget distributional analysis (#61). The resolver used to fail +# closed on this source, which was correct but temporary — the entry is +# made deliberately, with its evidence, BEFORE any numeric row can land, +# so the first digitized decile does not arrive needing an emergency +# relationship decision. +_HMT_DA_HELD = ( + CR.HELD_OUT, + "HM Treasury's distributional analysis is a closed departmental " + "microsimulation scored against HMT's own no-policy-change " + "counterfactual; no pe-uk-data target and no policyengine-uk " + "parameter is fitted to a decile impact from it (consumption " + "surfaces read 2026-08-19 at the certified pins — the only HMT " + "material with a consuming pin is the uk_hmt fiscal-event costings " + "family, which is itself held out). Its benefits-in-kind public-" + "services allocation has no PE counterpart at all, so a large part " + "of every bar is not_expressible rather than divergent.", +) + _UKMOD_HELD = ( CR.HELD_OUT, "UKMOD is a peer microsimulation, not a calibration source; no PE UK " @@ -278,6 +296,8 @@ def uk_relationship(source, metric, program=None, kind=None): return _OBR_UNCONSUMED if source == "ukmod": return _UKMOD_HELD + if source == "hmt_distributional": + return _HMT_DA_HELD if source == "hm_treasury": return (CR.HELD_OUT, "fiscal-event costings are scored, never consumed.") if source == "dwp": diff --git a/scorecard_db/uk_aliases.py b/scorecard_db/uk_aliases.py index 33d5a49..c501265 100644 --- a/scorecard_db/uk_aliases.py +++ b/scorecard_db/uk_aliases.py @@ -55,6 +55,11 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: ("dwp_takeup:housing_benefit_pensioners", "obr:housing_benefit_on_jsa"), ("dwp_takeup:housing_benefit_pensioners", "ukmod:housing_benefit"), ("dwp_takeup:benefit_units", "ukmod:families"), + # A decile of HMT's equivalised-net-income distribution is not a + # quintile of UKMOD's: different publications, different income + # concepts, different cut points. Never aliased. + ("hmt_distributional:decile_1", "ukmod:q1"), + ("hmt_distributional:decile_10", "ukmod:q5"), } ) @@ -299,6 +304,35 @@ def _alias(source: str, axis: str, source_value: str, canonical: str) -> None: _identity(_src, "unit", ["gbp", "households"]) +# --- HMT distributional analysis (#61) --------------------------------------- +# HMT ranks households by equivalised net household income (BHC) into ten +# deciles plus an all-households summary bar. Registered as CLOSED data +# identities so a future numeric row cannot carry "decile 1" as prose — +# and deliberately NOT unified with UKMOD's quintiles: a decile of one +# publication's distribution is not a quintile of another's, and the two +# rank on different income concepts. housing_costs=bhc and +# equivalisation=modified_oecd are load-bearing beside them, exactly as on +# the HBAI family. +_identity("hmt_distributional", "geography", ["UK"]) +_identity( + "hmt_distributional", + "income_group", + [f"decile_{_i}" for _i in range(1, 11)] + ["all_households"], +) +_identity("hmt_distributional", "housing_costs", ["bhc"]) +_identity( + "hmt_distributional", + "program", + ["hmt_distributional_analysis"], +) +_identity( + "hmt_distributional", + "subgroup", + ["tax", "welfare", "benefits_in_kind_public_services", "overall"], +) +_identity("hmt_distributional", "unit", ["percent", "gbp_nominal"]) + + def canon(source: str, axis: str, value: str) -> str: """Canonical value for (source, axis, source_value); unknown raises.""" try: diff --git a/sources/hmt-distributional/adapter.py b/sources/hmt-distributional/adapter.py new file mode 100644 index 0000000..d03c4bd --- /dev/null +++ b/sources/hmt-distributional/adapter.py @@ -0,0 +1,224 @@ +"""Adapter: HMT "Impact on households" (Budget 2025) -> verified structure. + +Input: raw/Impact_on_households.pdf (Budget 2025 supporting document, + published 2025-11-26, fetched from gov.uk — see raw/README.md) + ../../data/uk/hmt_da_packages.yaml (the package registry this + adapter verifies against the document) +Output: data/externals/hmt-distributional-meta.json — the verified + document structure: figure inventory, package composition counts, + and the value-availability statement. + +THIS ADAPTER EMITS NO VALUE CLAIMS, deliberately. HMT publishes the +decile impacts of Figures 1.A-1.C as unlabeled chart bars in a PDF; no +spreadsheet or chart-data file exists (the Budget 2025 supporting +documents carry XLSX only for the costings tables 4.1/4.2). The text +layer carries figure titles, axis ticks, the policy scope lists and the +methodology — those are verifiable, and this adapter verifies them: + + - the raw PDF's SHA-256 matches the registry entry (a silently swapped + or re-fetched file fails loudly) + - every figure the registry advertises appears in the document + - every package component title in the registry is verbatim-anchored in + the document's in-scope list (normalized for the PDF text layer's + line breaks and typographic quotes), so the registry cannot drift + from the publication it claims to describe + +Per-decile external values enter this lane only if HMT releases data +tables or a documented chart digitization lands with per-bar provenance. +Requires pypdf and pyyaml (like ukmod-stats requires pypdf); run: + uv run --with pypdf --with pyyaml python sources/hmt-distributional/adapter.py +""" + +import hashlib +import json +import re +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +RAW = HERE / "raw" / "Impact_on_households.pdf" +REGISTRY = ROOT / "data" / "uk" / "hmt_da_packages.yaml" +OUT_DIR = ROOT / "data" / "externals" + +EXPECTED_PAGES = 20 + +# Registered baseline worlds a chart may name. Closed: a figure whose +# baseline is not one of these raises, so a future numeric row cannot +# default to current law by omission. +CHART_BASELINES = {"hmt_no_policy_change_from_ab2024", "current_law"} +CHART_KINDS = {"change", "level"} +FIGURE_TITLES = { + "1.A": "Impact of decisions from Autumn Budget 2024", + "1.B": "in cash terms", + "1.C": "Overall level of public spending received", +} +COMPUTABILITY = {"expressible", "partial", "not_expressible"} + + +def normalize(text): + """Collapse the PDF text layer's line breaks, repeated whitespace and + typographic quotes/dashes so verbatim registry titles anchor.""" + text = text.replace("’", "'").replace("‘", "'") + text = text.replace("–", "-").replace("—", "-") + # PDF line breaks split hyphenated tokens ("non- standard", "2026- 27"); + # closing the gap after a hyphen is safe here because registry titles + # carry no hyphen-space sequences. + text = re.sub(r"-\s+", "-", text) + return re.sub(r"\s+", " ", text).strip() + + +def load_registry(): + import yaml + + reg = yaml.safe_load(REGISTRY.read_text()) + for pkg in reg["packages"]: + for comp in pkg["components"]: + assert comp["computability"] in COMPUTABILITY, comp["title"] + if comp["computability"] == "partial": + assert comp.get("missing"), f"partial without missing: {comp['title']}" + if comp["computability"] == "not_expressible": + assert comp.get("why"), f"not_expressible without why: {comp['title']}" + return reg + + +def document_text(): + from pypdf import PdfReader + + reader = PdfReader(RAW) + if len(reader.pages) != EXPECTED_PAGES: + raise ValueError(f"expected {EXPECTED_PAGES} pages, got {len(reader.pages)}") + return normalize(" ".join(p.extract_text() for p in reader.pages)) + + +def run(): + reg = load_registry() + pkg = next( + p + for p in reg["packages"] + if p["package_key"] == "budget_2025__impact_on_households" + ) + + sha = hashlib.sha256(RAW.read_bytes()).hexdigest() + if sha != pkg["sha256"]: + raise ValueError(f"raw PDF sha256 {sha} != registry {pkg['sha256']}") + + text = document_text() + + for fig, anchor in FIGURE_TITLES.items(): + if normalize(anchor) not in text: + raise ValueError(f"figure {fig} anchor not found: {anchor!r}") + registry_figs = {c["figure"] for c in pkg["charts"]} + if registry_figs != set(FIGURE_TITLES): + raise ValueError(f"registry charts {registry_figs} != document figures") + + unanchored = [] + for comp in pkg["components"]: + # Full verbatim titles span PDF line breaks; normalize() flattens + # both sides, so the whole title must anchor. + title = normalize(comp["title"]) + if title not in text: + unanchored.append(comp["title"]) + if unanchored: + raise ValueError(f"registry components not verbatim in document: {unanchored}") + + # --- exact chart-omission accounting ------------------------------- + # The registry lists figures and series but not the GROUPS, so the + # omission was untallied: "no values emitted" is only honest if it + # says how many there were. 3 figures x 11 income groups x 4 series = + # 132 marks on the page, every one of them chart_not_digitized. + groups = pkg["income_groups"] + if len(set(groups)) != len(groups): + raise ValueError("income_groups repeats a decile") + cells = [] + for chart in pkg["charts"]: + if chart["baseline"] not in CHART_BASELINES: + raise ValueError( + f"figure {chart['figure']}: unregistered baseline " + f"{chart['baseline']!r} — register the world in " + "scorecard_db/baselines.py and add it to CHART_BASELINES" + ) + if chart["kind"] not in CHART_KINDS: + raise ValueError(f"figure {chart['figure']}: unknown kind") + for group in groups: + for series in chart["split"]: + cells.append( + { + "figure": chart["figure"], + "income_group": group, + "series": series, + "disposition": "chart_not_digitized", + } + ) + expected = len(pkg["charts"]) * len(groups) * len(pkg["charts"][0]["split"]) + if len(cells) != expected or expected != 132: + raise ValueError( + f"chart-cell accounting does not close: {len(cells)} cells " + f"!= {expected} (3 figures x {len(groups)} groups x 4 series = 132)" + ) + by_figure = {} + for c in cells: + by_figure[c["figure"]] = by_figure.get(c["figure"], 0) + 1 + + counts = {"by_computability": {}, "by_channel": {}} + for comp in pkg["components"]: + for key, field in ( + ("by_computability", "computability"), + ("by_channel", "channel"), + ): + counts[key][comp[field]] = counts[key].get(comp[field], 0) + 1 + + OUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUT_DIR / "hmt-distributional-meta.json" + out.write_text( + json.dumps( + { + "source": "hmt-distributional", + "package_key": pkg["package_key"], + "raw_sha256": sha, + "figures_verified": sorted(FIGURE_TITLES), + "components": len(pkg["components"]), + "component_counts": counts, + "value_claims_emitted": 0, + # The omission, tallied: every mark on every chart, by + # figure / income group / series, with its disposition. + "chart_cells": { + "source_marks": len(cells), + "emitted": 0, + "chart_not_digitized": len(cells), + "by_figure": dict(sorted(by_figure.items())), + "income_groups": groups, + "series": pkg["charts"][0]["split"], + "cells": cells, + }, + # Decile identity as DATA, not prose: the closed group + # vocabulary plus the two conditions that are load-bearing + # beside it (the HBAI/UKMOD pattern). + "income_identity": { + "income_axis": "equivalised_net_household_income_decile", + "housing_costs": pkg["housing_costs"], + "equivalisation": pkg["equivalisation"], + "income_groups": groups, + }, + # Per-figure baseline world, so a future numeric row + # cannot default to current law by omission. + "chart_baselines": { + c["figure"]: {"kind": c["kind"], "baseline": c["baseline"]} + for c in pkg["charts"] + }, + "value_availability": reg["value_availability_rule"], + }, + indent=1, + ) + + "\n" + ) + print(f"verified {len(pkg['components'])} components, 3 figures -> {out}") + print( + f" chart cells: {len(cells)} source marks = 0 emitted " + f"+ {len(cells)} chart_not_digitized" + ) + print(f" computability: {counts['by_computability']}") + print(" value claims emitted: 0 (chart-only publication; see module docstring)") + + +if __name__ == "__main__": + run() diff --git a/sources/hmt-distributional/raw/Impact_on_households.pdf b/sources/hmt-distributional/raw/Impact_on_households.pdf new file mode 100644 index 0000000..3979628 Binary files /dev/null and b/sources/hmt-distributional/raw/Impact_on_households.pdf differ diff --git a/sources/hmt-distributional/raw/README.md b/sources/hmt-distributional/raw/README.md new file mode 100644 index 0000000..3f3b156 --- /dev/null +++ b/sources/hmt-distributional/raw/README.md @@ -0,0 +1,21 @@ +# Raw files — HMT Impact on households (Budget 2025) + +| file | Impact_on_households.pdf | +|---|---| +| title | Impact on households: distributional analysis to accompany Budget 2025 | +| publication | Supporting documents for Budget 2025 (HM Treasury) | +| page | https://www.gov.uk/government/publications/supporting-documents-for-budget-2025 | +| direct url | https://assets.publishing.service.gov.uk/media/69269c6222424e25e6bc31bb/Impact_on_households.pdf | +| published | 2025-11-26 (Budget 2025, 26 November 2025) | +| fetched | 2026-08-17 | +| sha256 | 9f7e68f3e44349c8a6a4a89b9e1427cc23f1691771902b154ef67cadd33480ea | +| ref | ISBN 978-1-917638-71-5, PU 3569 | + +HM Treasury publishes this analysis as a PDF only: the decile impacts in +Figures 1.A–1.C are drawn as unlabeled chart bars, and no spreadsheet or +chart-data file accompanies the publication (the Budget 2025 supporting +documents carry XLSX only for Tables 4.1/4.2, the costings tables). The +text layer of the PDF carries the figure titles, axis ticks, the policy +scope lists and the methodology — those are what the adapter verifies and +extracts. Per-decile values await chart digitization or an HMT data +release and are NOT emitted; nothing in this lane invents them. diff --git a/sources/hmt-distributional/source.json b/sources/hmt-distributional/source.json new file mode 100644 index 0000000..9c0e937 --- /dev/null +++ b/sources/hmt-distributional/source.json @@ -0,0 +1,24 @@ +{ + "id": "hmt-distributional", + "name": "HM Treasury — Impact on households: distributional analysis to accompany Budget 2025", + "organization": "HM Treasury", + "country": "UK", + "url": "https://www.gov.uk/government/publications/supporting-documents-for-budget-2025", + "data_urls": [ + "https://assets.publishing.service.gov.uk/media/69269c6222424e25e6bc31bb/Impact_on_households.pdf" + ], + "raw_note": "raw/ vendors the PDF only, because that is all HMT publishes: the decile impacts in Figures 1.A-1.C are unlabeled chart bars with no accompanying spreadsheet or chart-data file. The adapter verifies and extracts document structure (figure inventory, policy scope, methodology facts); per-decile values are not machine-readable and are not emitted.", + "published": "2025-11-26", + "fetched": "2026-08-17", + "model": { + "name": "HM Treasury distributional analysis model", + "open_source": false, + "method": "Static microsimulation of tax and welfare measures on survey data plus allocation of benefits-in-kind from public service spending, ranked by equivalised net household income (BHC) deciles. Cumulative impact of decisions announced from Autumn Budget 2024 onwards, shown in 2028-29 against a counterfactual of no policy changes over the period. Full compliance with tax and welfare rules assumed; behavioural responses generally excluded.", + "method_basis": "Chapter 2 (Data sources and methodology) of the publication" + }, + "period": "Impacts in 2028-29 (final year of Spending Review 2025)", + "programs": ["hmt_distributional_analysis"], + "geographies": "UK households (devolved administrations' own decisions excluded)", + "vintage_note": "Each fiscal event's edition re-scores the cumulative package against that event's counterfactual; editions are not comparable to each other row-by-row. Score only against the edition named in the claim's fiscal_event condition.", + "diagnosis_upstream": "Divergences decompose against package composition (which measures are in scope), income concept and equivalisation, the public-services benefits-in-kind allocation (not_expressible in PE), and the no-policy-change counterfactual baseline. Only after every one of those axes is sized may an engine defect be named — and then only through the citable-known-issue gate: a diagnosis carries a class, a rationale and an action_link to the upstream issue, exactly as every other diagnosed divergence does. 'May be inferred' is not a route; an uncited engine-defect claim is blame, not a finding." +} diff --git a/tests/test_hmt_da_adapter.py b/tests/test_hmt_da_adapter.py new file mode 100644 index 0000000..a9ca618 --- /dev/null +++ b/tests/test_hmt_da_adapter.py @@ -0,0 +1,251 @@ +"""Structure tests for the HMT distributional-analysis lane (#61). + +The lane deliberately emits no value claims (HMT publishes decile impacts +as unlabeled chart bars in a PDF; see sources/hmt-distributional/raw/ +README.md). What is pinned here is everything checkable without pypdf: +the vendored raw file's identity, the committed meta artifact's honesty +about that, and — when pyyaml is importable — the registry's schema +contract. Full document anchoring (every registry title verbatim in the +PDF) runs in the adapter itself, which needs pypdf. +""" + +import ast +import hashlib +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +LANE = ROOT / "sources" / "hmt-distributional" +RAW = LANE / "raw" / "Impact_on_households.pdf" +REGISTRY = ROOT / "data" / "uk" / "hmt_da_packages.yaml" +META = ROOT / "data" / "externals" / "hmt-distributional-meta.json" + +RAW_SHA256 = "9f7e68f3e44349c8a6a4a89b9e1427cc23f1691771902b154ef67cadd33480ea" + + +def test_raw_file_is_the_fetched_publication(): + assert RAW.exists(), "vendored raw PDF missing" + assert hashlib.sha256(RAW.read_bytes()).hexdigest() == RAW_SHA256 + + +def test_lane_provenance_files_exist(): + assert (LANE / "raw" / "README.md").exists() + source = json.loads((LANE / "source.json").read_text()) + assert source["id"] == "hmt-distributional" + assert RAW_SHA256 not in source["url"] # sha lives in README/registry + assert any(u.endswith("Impact_on_households.pdf") for u in source["data_urls"]) + + +def test_adapter_compiles_and_emits_no_value_rows_by_construction(): + src = (LANE / "adapter.py").read_text() + ast.parse(src) + # The no-values contract is stated and structural: the adapter has no + # emit path for claim rows at all. + assert "EMITS NO VALUE CLAIMS" in src + assert "value_claims_emitted" in src + + +def test_meta_artifact_is_honest_about_values(): + meta = json.loads(META.read_text()) + assert meta["value_claims_emitted"] == 0 + assert meta["raw_sha256"] == RAW_SHA256 + assert meta["figures_verified"] == ["1.A", "1.B", "1.C"] + assert meta["components"] == 30 + + +def test_registry_schema(): + import yaml + + reg = yaml.safe_load(REGISTRY.read_text()) + assert reg["benchmark_class"] == "different_model" + assert reg["value_availability_rule"] + keys = [p["package_key"] for p in reg["packages"]] + assert keys == ["budget_2025__impact_on_households"] + pkg = reg["packages"][0] + assert pkg["sha256"] == RAW_SHA256 + assert {c["figure"] for c in pkg["charts"]} == {"1.A", "1.B", "1.C"} + assert len(pkg["components"]) == 30 + for comp in pkg["components"]: + assert comp["computability"] in {"expressible", "partial", "not_expressible"} + assert comp["channel"] in {"tax", "welfare", "benefits_in_kind_public_services"} + if comp["computability"] == "partial": + assert comp.get("missing"), comp["title"] + if comp["computability"] == "not_expressible": + assert comp.get("why"), comp["title"] + # No invented executable specs: reform parameters live only in the + # measure registry a component may point at, never here. + assert "parameters" not in comp and "reform" not in comp + triage = {c["computability"] for c in pkg["components"]} + assert triage == {"expressible", "partial", "not_expressible"} + + +def _load_adapter(): + import importlib.util + + spec = importlib.util.spec_from_file_location("hmt_da_adapter", LANE / "adapter.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_document_anchoring_runs_in_ci(): + """The lane's strongest honesty guarantee — every registry title and + figure anchor is verbatim in the real PDF. It is NOT optional: CI now + installs pypdf and pyyaml, so this runs there. It used to + `importorskip` both while CI installed only pytest, which meant the + advertised gate never actually ran.""" + adapter = _load_adapter() + + text = adapter.document_text() # also enforces the 20-page identity + reg = adapter.load_registry() + pkg = next( + p + for p in reg["packages"] + if p["package_key"] == "budget_2025__impact_on_households" + ) + for fig, anchor in adapter.FIGURE_TITLES.items(): + assert adapter.normalize(anchor) in text, f"figure {fig} anchor missing" + assert {c["figure"] for c in pkg["charts"]} == set(adapter.FIGURE_TITLES) + unanchored = [ + c["title"] + for c in pkg["components"] + if adapter.normalize(c["title"]) not in text + ] + assert not unanchored, f"registry titles not verbatim in document: {unanchored}" + + +# --- the six review findings ------------------------------------------------ + + +def test_chart_omissions_are_exactly_tallied(): + """ "No values emitted" is only honest if it says how many there were. + 3 figures x 11 income groups x 4 series = 132 marks on the page.""" + meta = json.loads(META.read_text()) + cells = meta["chart_cells"] + assert cells["source_marks"] == 132 + assert cells["emitted"] == 0 + assert cells["chart_not_digitized"] == 132 + assert cells["by_figure"] == {"1.A": 44, "1.B": 44, "1.C": 44} + assert len(cells["cells"]) == 132 + assert {c["disposition"] for c in cells["cells"]} == {"chart_not_digitized"} + # every (figure, group, series) is present exactly once + keys = {(c["figure"], c["income_group"], c["series"]) for c in cells["cells"]} + assert len(keys) == 132 + # ...and the 30 policy components still reconcile (3+9+18 = 16+9+5) + comp = meta["component_counts"] + assert sum(comp["by_computability"].values()) == 30 + assert sum(comp["by_channel"].values()) == 30 + + +def test_decile_identity_is_closed_data_not_prose(): + from scorecard_db.uk_aliases import DISTINCT, canon + + meta = json.loads(META.read_text()) + ident = meta["income_identity"] + assert ident["housing_costs"] == "bhc" + assert ident["equivalisation"] == "modified_oecd" + assert ident["income_groups"] == [f"decile_{i}" for i in range(1, 11)] + [ + "all_households" + ] + for group in ident["income_groups"]: + assert canon("hmt_distributional", "income_group", group) == group + with pytest.raises(ValueError, match="unregistered income_group"): + canon("hmt_distributional", "income_group", "bottom fifth") + # HMT deciles are never aliased to UKMOD quintiles + assert ("hmt_distributional:decile_1", "ukmod:q1") in DISTINCT + assert ("hmt_distributional:decile_10", "ukmod:q5") in DISTINCT + + +def test_baseline_identity_is_per_figure_and_registered(): + """1.A/1.B are changes vs the no-policy world; 1.C is a post-policy + level. One prose counterfactual over all three would let a future row + default to current_law.""" + from scorecard_db.baselines import BASELINES + + meta = json.loads(META.read_text()) + assert meta["chart_baselines"] == { + "1.A": {"kind": "change", "baseline": "hmt_no_policy_change_from_ab2024"}, + "1.B": {"kind": "change", "baseline": "hmt_no_policy_change_from_ab2024"}, + "1.C": {"kind": "level", "baseline": "current_law"}, + } + registered = {d["policy"] for d, *_ in BASELINES} + assert "hmt_no_policy_change_from_ab2024" in registered + + +def test_an_unregistered_chart_baseline_raises(tmp_path, monkeypatch): + adapter = _load_adapter() + reg = adapter.load_registry() + pkg = reg["packages"][0] + pkg["charts"][0]["baseline"] = "whatever_hmt_meant" + monkeypatch.setattr(adapter, "load_registry", lambda: reg) + monkeypatch.setattr(adapter, "OUT_DIR", tmp_path) + with pytest.raises(ValueError, match="unregistered baseline"): + adapter.run() + + +def test_the_lane_survives_a_fresh_build(tmp_path): + """The blocking finding: the adapter wrote standalone metadata and + nothing registered in build_db, so a CI-built DB held no trace of + HMT — not even a zero-claim lane.""" + import sqlite3 + + from scorecard_db.db import ScorecardDB + from scorecard_db.ingest_hmt_distributional import LANE_ID, ingest + + db_path = tmp_path / "db.sqlite" + ScorecardDB(db_path).close() + summary = ingest(db_path) + assert summary["claims"] == 0 + assert summary["source_marks"] == 132 + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + lane = conn.execute("SELECT * FROM lanes WHERE lane = ?", (LANE_ID,)).fetchone() + conn.close() + assert lane is not None + assert lane["stage"] == "cataloged" + assert "132 source marks = 0 emitted + 132 chart_not_digitized" in lane["detail"] + + +def test_build_db_registers_the_hmt_step(): + import inspect + + from scorecard_db import build_db + + src = inspect.getsource(build_db) + assert "ingest_hmt_distributional.ingest" in src + + +def test_the_lane_reaches_mission_control(): + lanes = json.loads((ROOT / "data" / "lanes.json").read_text())["lanes"] + entry = next(lane for lane in lanes if lane["id"] == "hmt-distributional") + assert entry["country"] == "UK" + assert entry["stage"] == "cataloged" + + +def test_held_out_relationship_is_registered_with_evidence(): + """The resolver failed closed on the unknown source, which was right + but temporary: the entry is made before any numeric row can land.""" + from scorecard_db.models import CalibrationRelationship, Metric + from scorecard_db.relationships import uk_relationship + + rel, evidence = uk_relationship( + "hmt_distributional", Metric.PCT_CHANGE_AFTER_TAX_INCOME + ) + assert rel is CalibrationRelationship.HELD_OUT + assert "no pe-uk-data target" in evidence + assert "2026-08-19" in evidence # the pin the surfaces were read at + + +def test_engine_defect_guidance_points_at_the_citable_gate(): + source = json.loads((LANE / "source.json").read_text()) + text = source["diagnosis_upstream"] + assert "action_link" in text + assert "citable-known-issue gate" in text + + +def test_the_meta_artifact_ends_with_a_newline(): + """The no-drift build rewrites this file; without the trailing newline + the committed copy and a rebuild differ and the tree dirties.""" + assert META.read_text().endswith("}\n")