From c147a8d4c63d7a0a765908791bb35846bcc5ec34 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 19 Aug 2026 17:58:23 -0400 Subject: [PATCH 1/4] Campaign-UK staging producer: archive -> resolved claim-id joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adjudicated replacement for #32's campaign-UK ingester: a deterministic producer that resolves the frozen 2026-08-02 UK campaign staging against the ingested claims instead of loosening the match contract. Chain (every step closed, raises on 0/2+): archived row construction -> executed uk_run -> t2-collation HMRC change label -> exactly one uk_hmrc reckoner claim -> the strict {claim_id} match form. hmrc_reckoner_t2 resolves (14 rows, collation hints verified equal to DB option strings character-for-character). free_joins (OBR receipts = pe-uk-data calibration surfaces, claims not staged), obr_measures (OBR costings DB, long-tail held), two_child + uprating (Resolution Foundation, long-tail held) are blocked-with-reasons in the module; a new archive family without a disposition fails the produce. ingest_campaign __main__ gains the staged_dir argument it already accepted programmatically. DB ingest deliberately NOT in this commit — it lands after #52 merges so the committed DB advances linearly. Co-Authored-By: Claude Fable 5 --- scorecard_db/ingest_campaign.py | 3 +- scorecard_db/produce_campaign_uk.py | 186 ++++++++++++++++++++++++++++ tests/test_campaign_uk_producer.py | 155 +++++++++++++++++++++++ 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 scorecard_db/produce_campaign_uk.py create mode 100644 tests/test_campaign_uk_producer.py diff --git a/scorecard_db/ingest_campaign.py b/scorecard_db/ingest_campaign.py index efbbc24..6a9cb5e 100644 --- a/scorecard_db/ingest_campaign.py +++ b/scorecard_db/ingest_campaign.py @@ -263,4 +263,5 @@ def ingest(db_path: Path, staged_dir: Path | None = None) -> dict: import sys out = Path(sys.argv[1] if len(sys.argv) > 1 else "data/scorecard.db") - print(json.dumps(ingest(out), indent=1)) + staged = Path(sys.argv[2]) if len(sys.argv) > 2 else None + print(json.dumps(ingest(out, staged), indent=1)) diff --git a/scorecard_db/produce_campaign_uk.py b/scorecard_db/produce_campaign_uk.py new file mode 100644 index 0000000..cb54104 --- /dev/null +++ b/scorecard_db/produce_campaign_uk.py @@ -0,0 +1,186 @@ +"""Resolve the archived UK campaign staging against the ingested UK claims. + +The 2026-08-02 compute campaign staged five UK families before any UK +claims existed in the DB; the archive is frozen under +sources/campaign-20260802/uk (PR #70). Measured against the ingested +vocabulary (PR #48) the archived descriptors under-specify — the +adjudication's finding: two personal-allowance reckoner rows share +identical descriptor conditions, and the descriptor periods key the PE +calendar year, not the claim convention (FY END year). This producer +DERIVES a resolved staging deterministically instead of loosening the +match contract: + + archive row.pe_construction (its " | " prefix, an exact string) + -> uk_runs/_2026.json pe_construction (the executed run) + -> t2_collation.csv hmrc_hint (HMRC's verbatim change + label for that run) + -> exactly ONE ingested claim: source uk_hmrc, metric + revenue_change, conditions.option == hint, conditions.fy == + the archived row's fy [fail-loud] + -> {"claim_id": ...} (ingest_campaign's strict direct form) + +Every step is a closed lookup that raises on 0 or 2+ — a drifted +archive, collation, or claim re-ingest fails loudly, never mis-joins. + +Family disposition (this module's summary reports it): + hmrc_reckoner_t2 RESOLVED (14 rows) -> uk_resolved/ + free_joins NOT RESOLVED: targets OBR receipts forecast + lines, which are not staged as claims — pe-uk-data consumes EFO + receipts tables as calibration targets, so staging them is + relationship-evidence work (its own lane), never a quick join + obr_measures NOT RESOLVED: targets the OBR policy-measures + costings database (long-tail source, held on the DB-storage + decision) + two_child NOT RESOLVED: targets Resolution Foundation + claims (long-tail source, held) + uprating_april2026 NOT RESOLVED: 3 of 4 rows target Resolution + Foundation benefit_uprating_pct claims (long-tail, held); the + 4th is an exhibit row without exhibit_meta, which would only + ever defer — nothing attachable until RF stages + +Usage: + PYTHONPATH=. python -m scorecard_db.produce_campaign_uk + PYTHONPATH=. python -m scorecard_db.ingest_campaign \ + data/scorecard.db sources/campaign-20260802/uk_resolved +""" + +from __future__ import annotations + +import csv +import json +from pathlib import Path + +from .db import ScorecardDB +from .harvest import REPO + +CAMPAIGN = REPO / "sources" / "campaign-20260802" +ARCHIVE = CAMPAIGN / "uk" +RUNS = CAMPAIGN / "uk_runs" +RESOLVED = CAMPAIGN / "uk_resolved" + +# Archived families this producer deliberately does NOT resolve, with +# the reason a future lane must clear first. Every archive family must +# appear either here or in the resolve/copy sets — a new family in the +# archive fails loudly rather than being silently skipped. +BLOCKED = { + "free_joins": ( + "targets OBR receipts forecast lines not staged as claims; " + "pe-uk-data consumes EFO receipts tables as calibration " + "targets — staging needs the relationship evidence read at the " + "pin (its own lane)" + ), + "obr_measures": ( + "targets the OBR policy-measures costings database — long-tail " + "source held on the DB-storage decision" + ), + "two_child": ( + "targets Resolution Foundation claims — long-tail source held " + "on the DB-storage decision" + ), + "uprating_april2026": ( + "3 of 4 rows target Resolution Foundation benefit_uprating_pct " + "claims (long-tail, held); the 4th is an exhibit row without " + "exhibit_meta — nothing attachable until RF stages" + ), +} +RESOLVED_FAMILIES = {"hmrc_reckoner_t2"} + + +def _runs_by_construction() -> dict[str, str]: + """pe_construction -> reform_key over the archived executed runs.""" + out: dict[str, str] = {} + for f in sorted(RUNS.glob("*_2026.json")): + d = json.loads(f.read_text()) + if isinstance(d, dict) and "pe_construction" in d and "reform_key" in d: + if d["pe_construction"] in out: + raise ValueError( + f"two runs share a construction: {d['pe_construction']!r}" + ) + out[d["pe_construction"]] = d["reform_key"] + if not out: + raise FileNotFoundError(f"no run files under {RUNS}") + return out + + +def _hints_by_key() -> dict[str, str]: + """reform_key -> HMRC's verbatim change label (t2 collation).""" + with open(RUNS / "t2_collation.csv") as f: + rows = list(csv.DictReader(f)) + out = {r["key"]: r["hmrc_hint"] for r in rows} + if len(out) != len(rows): + raise ValueError("t2_collation.csv has duplicate keys") + return out + + +def _resolve_reckoner(db: ScorecardDB, rows: list[dict]) -> list[dict]: + constructions = _runs_by_construction() + hints = _hints_by_key() + resolved = [] + for row in rows: + prefix = row["pe_construction"].split(" | ")[0] + if prefix not in constructions: + raise ValueError( + f"hmrc_reckoner_t2: construction {prefix!r} matches no archived run" + ) + key = constructions[prefix] + if key not in hints: + raise ValueError(f"hmrc_reckoner_t2: run {key!r} not in t2 collation") + option = hints[key] + fy = row["external_claim_match"]["conditions"]["fy"] + hits = [ + r[0] + for r in db.conn.execute( + "SELECT claim_id FROM external_scores" + " WHERE source='uk_hmrc' AND metric='revenue_change'" + " AND json_extract(conditions, '$.option') = ?" + " AND json_extract(conditions, '$.fy') = ?", + (option, fy), + ) + ] + if len(hits) != 1: + raise ValueError( + f"hmrc_reckoner_t2: {len(hits)} claims for option " + f"{option!r} fy {fy!r} — need exactly one" + ) + out = dict(row) + out["external_claim_match"] = {"claim_id": hits[0]} + resolved.append(out) + if len({r["external_claim_match"]["claim_id"] for r in resolved}) != len(resolved): + raise ValueError("hmrc_reckoner_t2: two rows resolved to one claim") + return resolved + + +def produce(db_path: Path, out_dir: Path | None = None) -> dict: + """Write the resolved staging; returns the disposition summary.""" + out_dir = out_dir or RESOLVED + db = ScorecardDB(db_path) + families = {p.stem: p for p in sorted(ARCHIVE.glob("*.jsonl"))} + unaccounted = (set(families) - set(BLOCKED) - RESOLVED_FAMILIES) | ( + RESOLVED_FAMILIES - set(families) + ) + if unaccounted: + raise ValueError( + f"archive families without a disposition: {sorted(unaccounted)}" + ) + out_dir.mkdir(parents=True, exist_ok=True) + summary: dict = {"resolved": {}, "blocked": BLOCKED} + for name, path in families.items(): + rows = [ + json.loads(line) for line in path.read_text().splitlines() if line.strip() + ] + if name in BLOCKED: + continue + resolved = _resolve_reckoner(db, rows) + (out_dir / path.name).write_text( + "\n".join(json.dumps(r, sort_keys=True) for r in resolved) + "\n" + ) + summary["resolved"][name] = len(resolved) + db.close() + return summary + + +if __name__ == "__main__": + import sys + + db = Path(sys.argv[1] if len(sys.argv) > 1 else "data/scorecard.db") + print(json.dumps(produce(db), indent=1)) diff --git a/tests/test_campaign_uk_producer.py b/tests/test_campaign_uk_producer.py new file mode 100644 index 0000000..00d1eb3 --- /dev/null +++ b/tests/test_campaign_uk_producer.py @@ -0,0 +1,155 @@ +"""Tests for the UK campaign staging producer (archive -> resolved).""" + +import json +import shutil + +import pytest + +from scorecard_db import ScorecardDB +from scorecard_db.produce_campaign_uk import ARCHIVE, RUNS, produce + +DB = ARCHIVE.parent.parent.parent / "data" / "scorecard.db" + +pytestmark = pytest.mark.skipif( + not (ARCHIVE.exists() and DB.exists()), + reason="campaign archive or committed DB not present", +) + + +def test_produce_resolves_reckoner_and_blocks_the_rest(tmp_path): + summary = produce(DB, out_dir=tmp_path) + assert summary["resolved"] == {"hmrc_reckoner_t2": 14} + assert set(summary["blocked"]) == { + "free_joins", + "obr_measures", + "two_child", + "uprating_april2026", + } + # only the resolved family lands in the staging dir + assert [p.name for p in sorted(tmp_path.glob("*.jsonl"))] == [ + "hmrc_reckoner_t2.jsonl" + ] + + rows = [ + json.loads(line) + for line in (tmp_path / "hmrc_reckoner_t2.jsonl").read_text().splitlines() + ] + assert len(rows) == 14 + db = ScorecardDB(DB) + cids = set() + for r in rows: + match = r["external_claim_match"] + # the strict claim_id-direct form, nothing else + assert set(match) == {"claim_id"} + cids.add(match["claim_id"]) + hit = db.conn.execute( + "SELECT source, metric FROM external_scores WHERE claim_id = ?", + (match["claim_id"],), + ).fetchone() + assert hit is not None + assert (hit["source"], hit["metric"]) == ("uk_hmrc", "revenue_change") + # everything else preserved verbatim from the archive + assert r["run_id"] == "campaign-20260802-reckoner-t2" + assert r["status"] == "constructed" + db.close() + assert len(cids) == 14 # no two rows share a claim + + # the archive itself is untouched (frozen by #70) — descriptors there + # still carry the under-specified conditions form + archived = [ + json.loads(line) + for line in (ARCHIVE / "hmrc_reckoner_t2.jsonl").read_text().splitlines() + ] + assert all("claim_id" not in a["external_claim_match"] for a in archived) + + +def test_resolution_chain_is_closed(tmp_path, monkeypatch): + """A construction the archived runs never executed must fail the + produce, never guess a claim.""" + import scorecard_db.produce_campaign_uk as mod + + bad_dir = tmp_path / "uk" + bad_dir.mkdir() + for p in ARCHIVE.glob("*.jsonl"): + shutil.copy(p, bad_dir / p.name) + target = bad_dir / "hmrc_reckoner_t2.jsonl" + rows = [json.loads(line) for line in target.read_text().splitlines()] + rows[0]["pe_construction"] = "gov.made.up.parameter: 1 -> 2 | heads income_tax" + target.write_text("\n".join(json.dumps(r) for r in rows) + "\n") + monkeypatch.setattr(mod, "ARCHIVE", bad_dir) + with pytest.raises(ValueError, match="matches no"): + produce(DB, out_dir=tmp_path / "out") + + +def test_new_archive_family_needs_a_disposition(tmp_path, monkeypatch): + """Every archived family must be resolved, copied, or blocked-with- + reason — a new family cannot be silently skipped.""" + import scorecard_db.produce_campaign_uk as mod + + new_dir = tmp_path / "uk" + new_dir.mkdir() + for p in ARCHIVE.glob("*.jsonl"): + shutil.copy(p, new_dir / p.name) + (new_dir / "brand_new_family.jsonl").write_text("{}\n") + monkeypatch.setattr(mod, "ARCHIVE", new_dir) + with pytest.raises(ValueError, match="without a disposition"): + produce(DB, out_dir=tmp_path / "out") + + +def test_attach_round_trip(tmp_path): + """Resolved staging attaches through ingest_campaign's strict + claim_id-direct form: 14 results land and re-ingest is idempotent.""" + from scorecard_db.ingest_campaign import ingest + + db_copy = tmp_path / "scorecard.db" + shutil.copy(DB, db_copy) + staged = tmp_path / "uk_resolved" + produce(db_copy, out_dir=staged) + + summary = ingest(db_copy, staged_dir=staged) + assert summary["attached"] == 14 + assert summary["exhibits"] == 0 + assert summary["exhibits_deferred"] == [] + + again = ingest(db_copy, staged_dir=staged) + assert again["attached"] == 14 + + db = ScorecardDB(db_copy) + n = db.conn.execute( + "SELECT COUNT(*) FROM pe_results WHERE run_id = ?", + ("campaign-20260802-reckoner-t2",), + ).fetchone()[0] + assert n == 14 + # every attached result rides a claim in the reckoner family and the + # result side executed a current-law PE baseline (stamped by the + # campaign ingest), distinct from the claim's indexed HMRC baseline — + # the cross-world guard is what keeps these 'constructed' + cross = db.conn.execute( + "SELECT COUNT(*) FROM pe_results r JOIN external_scores s USING" + " (claim_id) WHERE r.run_id = 'campaign-20260802-reckoner-t2'" + " AND s.baseline_key = r.baseline_key" + ).fetchone()[0] + assert cross == 0 + db.close() + + +def test_collation_hints_match_db_options_exactly(): + """The resolution key: HMRC's verbatim change label in the t2 + collation equals the ingested claim's option string, character for + character, for every resolved row.""" + import csv + + with open(RUNS / "t2_collation.csv") as f: + hints = {r["key"]: r["hmrc_hint"] for r in csv.DictReader(f)} + db = ScorecardDB(DB) + options = { + r[0] + for r in db.conn.execute( + "SELECT DISTINCT json_extract(conditions, '$.option')" + " FROM external_scores WHERE source='uk_hmrc'" + " AND metric='revenue_change'" + ) + } + db.close() + missing = {k: h for k, h in hints.items() if h not in options} + assert not missing, missing From 45229857342bc69696db936234e63b92dc70c931 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 19 Aug 2026 18:39:14 -0400 Subject: [PATCH 2/4] Attach the resolved reckoner family: 14 UK campaign results on the committed DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit produce_campaign_uk resolved all 14 hmrc_reckoner_t2 rows to claim ids (collation hints == DB option strings verbatim) and ingest_campaign attached them: run_id campaign-20260802-reckoner-t2, engine 2.89.2 on the certified populace-uk-2023-dd68c73 bundle, all status constructed. Every result executed a PE current-law baseline while the claims score against hmrc_indexed_baseline_spring_2025 — the cross-baseline view guard holds all 14 at pe_status_effective='constructed', which is the truth of the comparison (PE CY2026 static accrual vs HMRC projected FY direct effects). populations.json regenerated: 270 -> 284 rows (the exporter includes every non-Urban claim with a result). Consistency pins updated deliberately: the provenance pin now lists both country bundles, and the idempotency test gains the stronger property the UK family makes testable — a US re-ingest must leave the UK run's rows untouched (deletion is scoped to staged run_ids). Suite: 235; committed DB byte-stable across the suite. Co-Authored-By: Claude Fable 5 --- data/populations.json | 971 +++++++++++++++++- data/scorecard.db | Bin 57634816 -> 57647104 bytes .../uk_resolved/hmrc_reckoner_t2.jsonl | 14 + tests/test_campaign_ingest.py | 21 +- 4 files changed, 1001 insertions(+), 5 deletions(-) create mode 100644 sources/campaign-20260802/uk_resolved/hmrc_reckoner_t2.jsonl diff --git a/data/populations.json b/data/populations.json index 931698b..28de83e 100644 --- a/data/populations.json +++ b/data/populations.json @@ -2,7 +2,7 @@ "built": "2026-08-19", "note": "Non-Urban populations exported from scorecard.db: the populace reform-validation registry (issue #20) plus the compute campaign's attached comparisons (TPC/CPSP/PWBM/CBO/JCT). Statuses and calibration relationships are verbatim; nothing here is a pass/fail grade.", "summary": { - "claims": 270, + "claims": 284, "multi_release_claims": 127, "by_source": { "az_admin": 1, @@ -55,6 +55,7 @@ "sc_admin": 1, "tpc": 10, "treasury": 1, + "uk_hmrc": 14, "ut_admin": 1, "va_admin": 1, "vt_admin": 2, @@ -64,7 +65,7 @@ "by_latest_status": { "comparable": 37, "concept_mismatch": 2, - "constructed": 231 + "constructed": 245 } }, "rows": [ @@ -20213,6 +20214,972 @@ ], "diagnosis": null }, + { + "claim_id": "23cb5f489c32e17b4b49", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change Savings allowance by \u00a3100 for BR and \u00a350 for HR taxpayers", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "05b7ff3832f3f02d", + "external_value": 0.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -69675676.99536133, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic: 1000 -> 1100; gov.hmrc.income_tax.allowances.personal_savings_allowance.higher: 500 -> 550 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 30000000.0 (ratio 2.322522566512044)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": null, + "delta": -69675676.99536133 + }, + "results": [ + { + "value": -69675676.99536133, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic: 1000 -> 1100; gov.hmrc.income_tax.allowances.personal_savings_allowance.higher: 500 -> 550 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 30000000.0 (ratio 2.322522566512044)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "39bb15c3c6084fd9c437", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "increase", + "country": "UK", + "direction": "cost", + "fy": "2026-27", + "geography": "UK", + "option": "Increase basic rate limit by 10% (cost)", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "61c2077173777dd4", + "external_value": 4600000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -6935879147.342651, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 41470.0 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 6250000000.0 (ratio 1.1097406635748241)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -1.507799814639707, + "delta": -11535879147.342651 + }, + "results": [ + { + "value": -6935879147.342651, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 41470.0 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 6250000000.0 (ratio 1.1097406635748241)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "4170013e721c13e0ddc1", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change personal allowance by 1%", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "aae15369320e1efc", + "external_value": 1000000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -1131417187.2644653, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_allowance.amount: 12570 -> 12695.7 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 1200000000.0 (ratio 0.9428476560537211)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -1.1314171872644654, + "delta": -2131417187.2644653 + }, + "results": [ + { + "value": -1131417187.2644653, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_allowance.amount: 12570 -> 12695.7 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 1200000000.0 (ratio 0.9428476560537211)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "5c6fc857d40327bb759c", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change personal allowance by 10%", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "b3c79dd8488bac94", + "external_value": 10000000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -11148223306.159363, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_allowance.amount: 12570 -> 13827.000000000002 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 11650000000.0 (ratio 0.9569290391553101)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -1.1148223306159364, + "delta": -21148223306.159363 + }, + "results": [ + { + "value": -11148223306.159363, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.personal_allowance.amount: 12570 -> 13827.000000000002 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 11650000000.0 (ratio 0.9569290391553101)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "6f708fb9feb19cb70eac", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "child_benefit", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "increase", + "country": "UK", + "direction": "cost", + "fy": "2026-27", + "geography": "UK", + "option": "Increase subsequent child rate by \u00a31 per week (cost)", + "program": "child_benefit", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "d674e5d7bd144e7f", + "external_value": 230000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": 307891828.10876465, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.child_benefit.amount.additional: 17.9 -> 18.9 | heads child_benefit", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 240000000.0 (ratio 1.2828826171198526)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": 1.3386601222120202, + "delta": 77891828.10876465 + }, + "results": [ + { + "value": 307891828.10876465, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.child_benefit.amount.additional: 17.9 -> 18.9 | heads child_benefit", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 240000000.0 (ratio 1.2828826171198526)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "8e1c1f9b430e9d996f24", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change Class 1 employee additional rate by 1 percentage point", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "63ae5174945c7cb7", + "external_value": 2000000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": 4391879294.86554, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.rates.employee.additional: 0.02 -> 0.03 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 2000000000.0 (ratio 2.1959396474327697)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": 2.1959396474327697, + "delta": 2391879294.8655396 + }, + "results": [ + { + "value": 4391879294.86554, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.rates.employee.additional: 0.02 -> 0.03 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 2000000000.0 (ratio 2.1959396474327697)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "b601650048d20e2606c9", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change upper profits limit by \u00a3520 per year", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "4637d737894b0e32", + "external_value": 10000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": 15204671.735198975, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit: 50270 -> 50790 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 10000000.0 (ratio 1.5204671735198974)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": 1.5204671735198974, + "delta": 5204671.735198975 + }, + "results": [ + { + "value": 15204671.735198975, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit: 50270 -> 50790 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 10000000.0 (ratio 1.5204671735198974)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "b8be62add3033280162d", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change employee entry threshold by \u00a32 per week", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "b7bd756c288799e3", + "external_value": 210000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -202893958.81115723, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold: 241.73 -> 243.73 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 230000000.0 (ratio 0.8821476470050315)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -0.9661617086245582, + "delta": -412893958.8111572 + }, + "results": [ + { + "value": -202893958.81115723, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold: 241.73 -> 243.73 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 230000000.0 (ratio 0.8821476470050315)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "d2fbcdc3204a5b6e27d5", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change upper earnings limit by \u00a310 per week", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "0bb1cd60307ae735", + "external_value": 220000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": 248033861.83112335, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit: 966.73 -> 976.73 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 220000000.0 (ratio 1.1274266446869243)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": 1.1274266446869243, + "delta": 28033861.831123352 + }, + "results": [ + { + "value": 248033861.83112335, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit: 966.73 -> 976.73 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 220000000.0 (ratio 1.1274266446869243)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "e05ac73d999627cb18cb", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change dividend allowance by \u00a3100", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "ef2cbab3e901fc34", + "external_value": 0.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -52777493.560424805, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.dividend_allowance: 500 -> 600 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 70000000.0 (ratio 0.7539641937203544)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": null, + "delta": -52777493.560424805 + }, + "results": [ + { + "value": -52777493.560424805, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.allowances.dividend_allowance: 500 -> 600 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 70000000.0 (ratio 0.7539641937203544)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "e2aac04633b000d98d17", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change starting rate limit for savings income by \u00a3100", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "0d59821a6c4d3eb2", + "external_value": null, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -33388707.427124023, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 5000 -> 5100 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 5000000.0 (ratio 6.6777414854248045)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": null, + "delta": null + }, + "results": [ + { + "value": -33388707.427124023, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 5000 -> 5100 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 5000000.0 (ratio 6.6777414854248045)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "eea5432a24523ee02327", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change employer threshold by \u00a32 per week", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "1e02c43b824ed2f7", + "external_value": 420000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -356685195.3192749, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold: 96 -> 98 | heads ni_employer", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 430000000.0 (ratio 0.8295004542308718)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -0.8492504650458926, + "delta": -776685195.3192749 + }, + "results": [ + { + "value": -356685195.3192749, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold: 96 -> 98 | heads ni_employer", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 430000000.0 (ratio 0.8295004542308718)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "fcefd20d8811df56657f", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "income_tax", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change basic rate limit by 1%", + "program": "income_tax", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "6bda9aaade34d6da", + "external_value": 495000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -725046338.0097046, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 38077.0 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 665000000.0 (ratio 1.0902952451273753)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -1.464740076787282, + "delta": -1220046338.0097046 + }, + "results": [ + { + "value": -725046338.0097046, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 38077.0 | heads income_tax", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 665000000.0 (ratio 1.0902952451273753)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, + { + "claim_id": "ff1a81248008fdad8c35", + "source": "uk_hmrc", + "source_column": "reckoner", + "name": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "window": "", + "publication_title": "Income Tax liabilities statistics 2023-24 to 2026-27 + June 2025 tax ready reckoner", + "url": "https://www.gov.uk/government/statistics/income-tax-liabilities-statistics-tax-year-2023-to-2024-to-tax-year-2026-to-2027", + "metric": "revenue_change", + "unit_concept": "gbp", + "value_kind": "gbp", + "period": 2027, + "time_basis": "fiscal_year", + "period_start": null, + "period_end": null, + "geography": "UK", + "program": "national_insurance", + "conditions": { + "baseline_policy": "hmrc_indexed_baseline_spring_2025", + "change_direction": "unsigned", + "country": "UK", + "direction": "stated_change", + "fy": "2026-27", + "geography": "UK", + "option": "Change lower profits limit by \u00a3104 per year", + "program": "national_insurance", + "sign_convention": "magnitude_with_direction_in_label" + }, + "reform_framework": "policy_ref", + "reform_key": "7a0446aba36175e3", + "external_value": 15000000.0, + "calibration_relationship": "held_out", + "claim_baseline": "hmrc_indexed_baseline_spring_2025", + "latest": { + "value": -17503727.481391907, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit: 12570 -> 12674 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 15000000.0 (ratio 1.1669151654261272)" + ], + "baseline": "current_law", + "status_effective": "constructed", + "ratio": -1.1669151654261272, + "delta": -32503727.481391907 + }, + "results": [ + { + "value": -17503727.481391907, + "status": "constructed", + "engine_version": "2.89.2", + "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "release": "8c73-4aa4b14", + "construction": "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit: 12570 -> 12674 | heads national_insurance", + "computed_at": "2026-08-06T18:42:04.081907+00:00", + "annotations": [ + "PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", + "fuller-year FY2027-28 external value 15000000.0 (ratio 1.1669151654261272)" + ], + "baseline": "current_law", + "status_effective": "constructed" + } + ], + "diagnosis": null + }, { "claim_id": "93368a42000c01f07cc3", "source": "ut_admin", diff --git a/data/scorecard.db b/data/scorecard.db index 4c82772303e2618ecc090ed12c29f70f1905f27b..dc72740c2c82b4eb6057ab938fc457a472d8f358 100644 GIT binary patch delta 14134 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 RJZ^{P40rCN6aBh`{{icsO7Z{z delta 6543 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 12695.7 | heads income_tax", "pe_value": -1131417187.2644653, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 11650000000.0 (ratio 0.9569290391553101)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "5c6fc857d40327bb759c"}, "pe_construction": "gov.hmrc.income_tax.allowances.personal_allowance.amount: 12570 -> 13827.000000000002 | heads income_tax", "pe_value": -11148223306.159363, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 665000000.0 (ratio 1.0902952451273753)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "fcefd20d8811df56657f"}, "pe_construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 38077.0 | heads income_tax", "pe_value": -725046338.0097046, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 6250000000.0 (ratio 1.1097406635748241)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "39bb15c3c6084fd9c437"}, "pe_construction": "gov.hmrc.income_tax.rates.uk[1].threshold: 37700 -> 41470.0 | heads income_tax", "pe_value": -6935879147.342651, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 5000000.0 (ratio 6.6777414854248045)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "e2aac04633b000d98d17"}, "pe_construction": "gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 5000 -> 5100 | heads income_tax", "pe_value": -33388707.427124023, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 30000000.0 (ratio 2.322522566512044)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "23cb5f489c32e17b4b49"}, "pe_construction": "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic: 1000 -> 1100; gov.hmrc.income_tax.allowances.personal_savings_allowance.higher: 500 -> 550 | heads income_tax", "pe_value": -69675676.99536133, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 70000000.0 (ratio 0.7539641937203544)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "e05ac73d999627cb18cb"}, "pe_construction": "gov.hmrc.income_tax.allowances.dividend_allowance: 500 -> 600 | heads income_tax", "pe_value": -52777493.560424805, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 230000000.0 (ratio 0.8821476470050315)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "b8be62add3033280162d"}, "pe_construction": "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold: 241.73 -> 243.73 | heads national_insurance", "pe_value": -202893958.81115723, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 430000000.0 (ratio 0.8295004542308718)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "eea5432a24523ee02327"}, "pe_construction": "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold: 96 -> 98 | heads ni_employer", "pe_value": -356685195.3192749, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 220000000.0 (ratio 1.1274266446869243)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "d2fbcdc3204a5b6e27d5"}, "pe_construction": "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit: 966.73 -> 976.73 | heads national_insurance", "pe_value": 248033861.83112335, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 15000000.0 (ratio 1.1669151654261272)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "ff1a81248008fdad8c35"}, "pe_construction": "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit: 12570 -> 12674 | heads national_insurance", "pe_value": -17503727.481391907, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 10000000.0 (ratio 1.5204671735198974)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "b601650048d20e2606c9"}, "pe_construction": "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit: 50270 -> 50790 | heads national_insurance", "pe_value": 15204671.735198975, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 240000000.0 (ratio 1.2828826171198526)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "6f708fb9feb19cb70eac"}, "pe_construction": "gov.hmrc.child_benefit.amount.additional: 17.9 -> 18.9 | heads child_benefit", "pe_value": 307891828.10876465, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} +{"annotations": ["PE CY2026 static accrual vs HMRC projected FY direct effects (first-year cash vs fuller-year; TIE/behavioral in HMRC reckoner per their methodology notes); |magnitude| ratios", "fuller-year FY2027-28 external value 2000000000.0 (ratio 2.1959396474327697)"], "computed_at": "2026-08-06T18:42:04.081907+00:00", "data_bundle": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", "engine_version": "2.89.2", "external_claim_match": {"claim_id": "8e1c1f9b430e9d996f24"}, "pe_construction": "gov.hmrc.national_insurance.class_1.rates.employee.additional: 0.02 -> 0.03 | heads national_insurance", "pe_value": 4391879294.86554, "run_id": "campaign-20260802-reckoner-t2", "status": "constructed"} diff --git a/tests/test_campaign_ingest.py b/tests/test_campaign_ingest.py index ba5b77c..ef60d6c 100644 --- a/tests/test_campaign_ingest.py +++ b/tests/test_campaign_ingest.py @@ -95,16 +95,22 @@ def test_full_attach_on_committed_db(db_copy): ("wic", "constructed"): 6, } assert all(r["urban"] == r["n"] for r in urb) - # Provenance verbatim from the staging: real engine + certified bundle. + # Provenance verbatim from the staging: real engine + certified + # bundle per country (the UK reckoner family rides the committed DB + # from the campaign-UK producer; the US ingest must not disturb it). rows = conn.execute( "SELECT DISTINCT engine_version, data_bundle FROM pe_results" - " WHERE run_id LIKE 'campaign-%'" + " WHERE run_id LIKE 'campaign-%' ORDER BY engine_version" ).fetchall() assert [tuple(r) for r in rows] == [ ( "1.764.6", "populace-us-2024-buildp-sparse-rmloss100-cae8640-20260728T011454Z", - ) + ), + ( + "2.89.2", + "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + ), ] # The CPSP TCJA-world attach lands on the claim whose reform is the # scenario (value 13.3% — the brief's number). @@ -181,19 +187,28 @@ def test_full_attach_on_committed_db(db_copy): def test_reingest_idempotent(db_copy): + UK_RUN = "campaign-20260802-reckoner-t2" first = ingest(db_copy) again = ingest(db_copy) assert again == first conn = sqlite3.connect(db_copy) n = conn.execute( "SELECT COUNT(*) FROM pe_results WHERE run_id LIKE 'campaign-%'" + " AND run_id != ?", + (UK_RUN,), ).fetchone()[0] n_ex = conn.execute( "SELECT COUNT(*) FROM pe_exhibits WHERE run_id LIKE 'campaign-%'" ).fetchone()[0] + # Deletion is scoped to the run_ids being re-ingested: the US + # re-ingest must leave the committed UK reckoner family untouched. + uk = conn.execute( + "SELECT COUNT(*) FROM pe_results WHERE run_id = ?", (UK_RUN,) + ).fetchone()[0] conn.close() assert n == first["attached"] assert n_ex == first["exhibits"] + assert uk == 14 def test_metaless_exhibit_defers(db_copy, tmp_path): From a5e8a6605f989f3125f70d528f29d7e7f71ba183 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 19 Aug 2026 18:57:16 -0400 Subject: [PATCH 3/4] Gate round 2: per-row-accurate blocked dispositions, pinned in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol's round-1 blocker: the BLOCKED reasons generalized each archive family from its first row. Re-tallied against the frozen archive (verified independently): free_joins = 7 obr revenue_level + 7 uk_dwp benefit_cost + 2 metaless exhibits (the uk_dwp half — DWP benefit- expenditure forecast lines — was omitted entirely); obr_measures = 9 + 1 metaless exhibit; two_child = 2 RF + 1 ukmod poverty_count_change (a REFORM claim — the staged UKMOD family is baseline statistics only) + 1 metaless exhibit; uprating unchanged. Reasons rewritten per-row; two new tests pin the exact compositions AND that every blocked target shape has zero DB claims — a re-frozen archive or a future source staging forces the deliberate re-disposition instead of a silent one. Also the two stale docs: ingest_campaign now describes the uk_resolved flow instead of claiming the UK has no DB ingest; the populations- export docstring names the uk_hmrc reckoner rows among its populations. Suite: 237; committed DB byte-stable. Co-Authored-By: Claude Fable 5 --- scorecard_db/export_populations.py | 16 ++++--- scorecard_db/ingest_campaign.py | 14 ++++-- scorecard_db/produce_campaign_uk.py | 67 ++++++++++++++++---------- tests/test_campaign_uk_producer.py | 73 +++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 37 deletions(-) diff --git a/scorecard_db/export_populations.py b/scorecard_db/export_populations.py index 3f9a3ca..104dfbc 100644 --- a/scorecard_db/export_populations.py +++ b/scorecard_db/export_populations.py @@ -2,13 +2,15 @@ The Urban SotSN population reaches the app through the file-based pipeline/build_comparison.py export (data/comparison.json). Everything -else — today, exactly the reform-validation registry (issue #20): its 205 -minted claims plus the 36 harvested JCX-35-25 provision claims its OBBBA -results attach to — lives only in scorecard.db. This module exports every -non-Urban claim that has at least one pe_result, carrying the dimension -the Urban export doesn't have: the full per-release result history -(one row per certified release, engine pins and OBBBA scoring mode in the -construction), so cross-release drift is visible. +else lives only in scorecard.db: the reform-validation registry (issue +#20, its 205 minted claims plus the 36 harvested JCX-35-25 provision +claims its OBBBA results attach to), the US campaign attaches, and — +since the campaign-UK producer — the 14 uk_hmrc reckoner claims with +campaign results. This module exports every non-Urban claim that has at +least one pe_result, carrying the dimension the Urban export doesn't +have: the full per-release result history (one row per certified +release, engine pins and OBBBA scoring mode in the construction), so +cross-release drift is visible. Doctrine (issues #1/#9): descriptive only. Statuses and calibration relationships are exported verbatim; ratios are raw pe/external with no diff --git a/scorecard_db/ingest_campaign.py b/scorecard_db/ingest_campaign.py index 6a9cb5e..367219e 100644 --- a/scorecard_db/ingest_campaign.py +++ b/scorecard_db/ingest_campaign.py @@ -9,11 +9,15 @@ family-vocabulary descriptor (translated below) or, for claims already in the DB (the Urban subgroup joins), the claim_id directly. -US families attach here. The UK families (free_joins, hmrc_reckoner_t2, -obr_measures, uprating_april2026, two_child) are vendored alongside but -NOT ingested: their claims live in the UK harvest, which has no DB ingest -yet — they attach when it lands, and this module fails loudly if pointed -at them early. +US families attach from sources/campaign-20260802/us. UK families +attach from sources/campaign-20260802/uk_resolved — the DERIVED staging +produce_campaign_uk builds from the frozen uk/ archive by resolving +each row to a claim_id against the ingested UK claims (today: +hmrc_reckoner_t2's 14 rows; the other archived families stay blocked +with per-row reasons in that module until their target sources are +staged). Pointing this module at the frozen uk/ archive directly still +fails loudly (its descriptors under-specify by design — resolution is +the producer's job). Match contract: descriptors were verified by the campaign against the harvest STAGING files; the DB's per-source adapters normalized vocabulary diff --git a/scorecard_db/produce_campaign_uk.py b/scorecard_db/produce_campaign_uk.py index cb54104..1b8fb03 100644 --- a/scorecard_db/produce_campaign_uk.py +++ b/scorecard_db/produce_campaign_uk.py @@ -22,21 +22,33 @@ Every step is a closed lookup that raises on 0 or 2+ — a drifted archive, collation, or claim re-ingest fails loudly, never mis-joins. -Family disposition (this module's summary reports it): +Family disposition (this module's summary reports it; the per-row +compositions below are pinned in tests so a re-frozen archive cannot +silently outgrow its stated reason): hmrc_reckoner_t2 RESOLVED (14 rows) -> uk_resolved/ - free_joins NOT RESOLVED: targets OBR receipts forecast - lines, which are not staged as claims — pe-uk-data consumes EFO - receipts tables as calibration targets, so staging them is - relationship-evidence work (its own lane), never a quick join - obr_measures NOT RESOLVED: targets the OBR policy-measures - costings database (long-tail source, held on the DB-storage - decision) - two_child NOT RESOLVED: targets Resolution Foundation - claims (long-tail source, held) - uprating_april2026 NOT RESOLVED: 3 of 4 rows target Resolution - Foundation benefit_uprating_pct claims (long-tail, held); the - 4th is an exhibit row without exhibit_meta, which would only - ever defer — nothing attachable until RF stages + free_joins NOT RESOLVED (16 rows = 7 obr revenue_level + + 7 uk_dwp benefit_cost + 2 metaless exhibits): the OBR rows + target EFO receipts forecast lines and the uk_dwp rows DWP + benefit-expenditure forecast lines — NEITHER is staged as + claims, and pe-uk-data consumes both publication families as + calibration surfaces, so staging them is relationship-evidence + work (two source lanes), never a quick join + obr_measures NOT RESOLVED (10 rows = 9 obr revenue_change + fiscal-event costings + 1 metaless exhibit): targets the OBR + policy-measures costings database (long-tail source, held on + the DB-storage decision) + two_child NOT RESOLVED (4 rows = 2 resolution_foundation + + 1 ukmod poverty_count_change + 1 metaless exhibit): RF is a + long-tail source (held), and the ukmod row targets a REFORM + claim — the staged UKMOD family is baseline validation + statistics only, so a 2CL-reform claim needs its own staging + decision + uprating_april2026 NOT RESOLVED (4 rows = 3 resolution_foundation + benefit_uprating_pct + 1 metaless exhibit): RF long-tail, held + +The metaless exhibits (4 across the families) carry exhibit_context +but no exhibit_meta; ingest_campaign would only ever defer them, so +they block with their families rather than shipping as noise. Usage: PYTHONPATH=. python -m scorecard_db.produce_campaign_uk @@ -64,23 +76,28 @@ # archive fails loudly rather than being silently skipped. BLOCKED = { "free_joins": ( - "targets OBR receipts forecast lines not staged as claims; " - "pe-uk-data consumes EFO receipts tables as calibration " - "targets — staging needs the relationship evidence read at the " - "pin (its own lane)" + "16 rows = 7 obr revenue_level (EFO receipts forecast lines) + " + "7 uk_dwp benefit_cost (DWP benefit-expenditure forecast " + "lines) + 2 metaless exhibits; neither publication family is " + "staged as claims, and pe-uk-data consumes both as calibration " + "surfaces — staging needs the relationship evidence read at " + "the pin (two source lanes)" ), "obr_measures": ( - "targets the OBR policy-measures costings database — long-tail " - "source held on the DB-storage decision" + "10 rows = 9 obr revenue_change fiscal-event costings + 1 " + "metaless exhibit; targets the OBR policy-measures costings " + "database — long-tail source held on the DB-storage decision" ), "two_child": ( - "targets Resolution Foundation claims — long-tail source held " - "on the DB-storage decision" + "4 rows = 2 resolution_foundation + 1 ukmod " + "poverty_count_change + 1 metaless exhibit; RF is long-tail " + "(held), and the ukmod row targets a REFORM claim — the staged " + "UKMOD family is baseline statistics only, so a 2CL-reform " + "claim needs its own staging decision" ), "uprating_april2026": ( - "3 of 4 rows target Resolution Foundation benefit_uprating_pct " - "claims (long-tail, held); the 4th is an exhibit row without " - "exhibit_meta — nothing attachable until RF stages" + "4 rows = 3 resolution_foundation benefit_uprating_pct + 1 " + "metaless exhibit; RF long-tail, held" ), } RESOLVED_FAMILIES = {"hmrc_reckoner_t2"} diff --git a/tests/test_campaign_uk_producer.py b/tests/test_campaign_uk_producer.py index 00d1eb3..cd1adc3 100644 --- a/tests/test_campaign_uk_producer.py +++ b/tests/test_campaign_uk_producer.py @@ -63,6 +63,79 @@ def test_produce_resolves_reckoner_and_blocks_the_rest(tmp_path): assert all("claim_id" not in a["external_claim_match"] for a in archived) +def _composition(name): + rows = [ + json.loads(line) + for line in (ARCHIVE / f"{name}.jsonl").read_text().splitlines() + if line.strip() + ] + tally: dict[str, int] = {} + for r in rows: + if r.get("exhibit") and "external_claim_match" not in r: + key = "metaless_exhibit" + else: + m = r["external_claim_match"] + key = f"{m['source']}:{m['metric']}" + tally[key] = tally.get(key, 0) + 1 + return tally + + +def test_blocked_dispositions_match_the_archive_exactly(): + """The stated reasons are per-row accurate (round-1 gate: the first + reasons generalized each family from its first row — free_joins is + NOT just OBR receipts). A re-frozen archive that changes any + family's composition fails here and forces a new disposition.""" + assert _composition("free_joins") == { + "obr:revenue_level": 7, + "uk_dwp:benefit_cost": 7, + "metaless_exhibit": 2, + } + assert _composition("obr_measures") == { + "obr:revenue_change": 9, + "metaless_exhibit": 1, + } + assert _composition("two_child") == { + "resolution_foundation:poverty_count_change": 1, + "resolution_foundation:reform_fiscal_cost": 1, + "ukmod:poverty_count_change": 1, + "metaless_exhibit": 1, + } + assert _composition("uprating_april2026") == { + "resolution_foundation:benefit_uprating_pct": 3, + "metaless_exhibit": 1, + } + + +def test_blocked_targets_have_no_claims_yet(): + """'Genuinely blocked' is machine-checked: zero DB claims exist for + every blocked target shape. When a future lane stages one of these + (OBR receipts, DWP expenditure forecasts, the OBR costings DB, RF, + a UKMOD 2CL-reform claim), this fails and forces the deliberate + unblock instead of a silent one.""" + db = ScorecardDB(DB) + + def n(sql, *args): + return db.conn.execute(sql, args).fetchone()[0] + + assert ( + n( + "SELECT COUNT(*) FROM external_scores WHERE source='obr'" + " AND metric IN ('revenue_level', 'revenue_change')" + ) + == 0 + ) + for source in ("uk_dwp", "resolution_foundation"): + assert n("SELECT COUNT(*) FROM external_scores WHERE source=?", source) == 0 + assert ( + n( + "SELECT COUNT(*) FROM external_scores WHERE source='ukmod'" + " AND metric='poverty_count_change'" + ) + == 0 + ) + db.close() + + def test_resolution_chain_is_closed(tmp_path, monkeypatch): """A construction the archived runs never executed must fail the produce, never guess a claim.""" From caa5dcc1f5e8f42808780c5aee2cff47545ac5b4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 19 Aug 2026 19:10:27 -0400 Subject: [PATCH 4/4] Gate round 3: 5 metaless rows (4 distinct); metaless = missing exhibit_meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol's round-2 items: - The docstring said 4 metaless exhibits; the per-row total is 5 — free_joins' pair is one construction duplicated. Verified against the archive before rewording. - The composition tally classified "metaless" by missing external_claim_match, never checking the DEFINING property: a refreeze adding exhibit_meta would keep the guards green while making the row ingestible. Meta-bearing exhibit rows now tally under their own key, so that refreeze changes the pinned dicts and forces the re-disposition. - The populations export note (emitted into populations.json) named only the US campaign sources; it now names the UK reckoner family, and the artifact is regenerated (note only — 284 rows unchanged). Suite: 237; committed DB byte-stable. Co-Authored-By: Claude Fable 5 --- data/populations.json | 2 +- scorecard_db/export_populations.py | 3 ++- scorecard_db/produce_campaign_uk.py | 7 ++++--- tests/test_campaign_uk_producer.py | 6 +++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/data/populations.json b/data/populations.json index 28de83e..44aaec1 100644 --- a/data/populations.json +++ b/data/populations.json @@ -1,6 +1,6 @@ { "built": "2026-08-19", - "note": "Non-Urban populations exported from scorecard.db: the populace reform-validation registry (issue #20) plus the compute campaign's attached comparisons (TPC/CPSP/PWBM/CBO/JCT). Statuses and calibration relationships are verbatim; nothing here is a pass/fail grade.", + "note": "Non-Urban populations exported from scorecard.db: the populace reform-validation registry (issue #20) plus the compute campaign's attached comparisons (US: TPC/CPSP/PWBM/CBO/JCT; UK: the HMRC ready-reckoner family). Statuses and calibration relationships are verbatim; nothing here is a pass/fail grade.", "summary": { "claims": 284, "multi_release_claims": 127, diff --git a/scorecard_db/export_populations.py b/scorecard_db/export_populations.py index 104dfbc..4dc0177 100644 --- a/scorecard_db/export_populations.py +++ b/scorecard_db/export_populations.py @@ -195,7 +195,8 @@ def export( "note": ( "Non-Urban populations exported from scorecard.db: the populace" " reform-validation registry (issue #20) plus the compute" - " campaign's attached comparisons (TPC/CPSP/PWBM/CBO/JCT)." + " campaign's attached comparisons (US: TPC/CPSP/PWBM/CBO/JCT;" + " UK: the HMRC ready-reckoner family)." " Statuses and calibration relationships are verbatim; nothing" " here is a pass/fail grade." ), diff --git a/scorecard_db/produce_campaign_uk.py b/scorecard_db/produce_campaign_uk.py index 1b8fb03..baadd81 100644 --- a/scorecard_db/produce_campaign_uk.py +++ b/scorecard_db/produce_campaign_uk.py @@ -46,9 +46,10 @@ uprating_april2026 NOT RESOLVED (4 rows = 3 resolution_foundation benefit_uprating_pct + 1 metaless exhibit): RF long-tail, held -The metaless exhibits (4 across the families) carry exhibit_context -but no exhibit_meta; ingest_campaign would only ever defer them, so -they block with their families rather than shipping as noise. +The metaless exhibits (5 rows across the families — the free_joins +pair is one construction duplicated) carry exhibit_context but no +exhibit_meta; ingest_campaign would only ever defer them, so they +block with their families rather than shipping as noise. Usage: PYTHONPATH=. python -m scorecard_db.produce_campaign_uk diff --git a/tests/test_campaign_uk_producer.py b/tests/test_campaign_uk_producer.py index cd1adc3..c49bbe7 100644 --- a/tests/test_campaign_uk_producer.py +++ b/tests/test_campaign_uk_producer.py @@ -72,7 +72,11 @@ def _composition(name): tally: dict[str, int] = {} for r in rows: if r.get("exhibit") and "external_claim_match" not in r: - key = "metaless_exhibit" + # the DEFINING property is the missing exhibit_meta (that is + # what makes ingest defer it) — a refreeze that adds meta + # makes the row ingestible and must change this tally, not + # slip through as "still metaless" (round-2 gate) + key = "metaless_exhibit" if "exhibit_meta" not in r else "exhibit_with_meta" else: m = r["external_claim_match"] key = f"{m['source']}:{m['metric']}"