From 154d5b82fb2079c0fbd6789c9f7e4c3624379e63 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 17:07:10 -0400 Subject: [PATCH 01/18] Apply the agreed d1344 replay policy in the replay gate Policy d1344-replay-regressions-v1, decided by W4 under cos d1344 after two independent reviews (the first disagreed, the second agreed with changes, which this applies). The gate fails what a receipt identifies as a regression: mapping flags; lost results (a coverage row with a base value and no candidate value); lost responses (a measure-year the candidate lists as inert, or a measure-year total of exactly 0 against a base whose absolute value reaches the diff's bound, max(1m, 1%)); and incomplete grids. A gap is known only on base-side evidence, which no receipt carries yet, so there are no known gaps. Every other flagged row goes to review: numerical, structural (sign reversals included), construction and support flags, coverage gains and null/null rows, and inertness on a measure-year the candidate does not list. The policy text ships as data/qualification/replay_policy.md, and the gate records its id, the text's SHA-256, the known gaps, the regressions and the review items. Tests pin the decision record's regression table and the bound's edges, check the first real receipt (6.4.1) fails on exactly its four inert UC measure-years and the incomplete grid with 346 review items, and state the invariants as a property: fail exactly when a regression is recorded, no flagged row ever passes, mapping and lost results always fail, and adding a row never improves the verdict. Nine mutants are killed. Co-Authored-By: Claude Opus 5.5 --- .../w4-d1344-automatic-promotion.changed.md | 1 + .../data/qualification/replay_policy.md | 55 + src/policyengine/provenance/qualification.py | 194 +- tests/fixtures/uk_replay/README.md | 7 + .../REPLAY_RECEIPT.json | 4480 +++++++++++++++++ .../receipt_6.4.1_attempt1/REQUEST.json | 59 + tests/test_qualification_contracts.py | 418 +- 7 files changed, 5157 insertions(+), 57 deletions(-) create mode 100644 changelog.d/w4-d1344-automatic-promotion.changed.md create mode 100644 src/policyengine/data/qualification/replay_policy.md create mode 100644 tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REPLAY_RECEIPT.json create mode 100644 tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REQUEST.json diff --git a/changelog.d/w4-d1344-automatic-promotion.changed.md b/changelog.d/w4-d1344-automatic-promotion.changed.md new file mode 100644 index 000000000..c757edcf3 --- /dev/null +++ b/changelog.d/w4-d1344-automatic-promotion.changed.md @@ -0,0 +1 @@ +The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. diff --git a/src/policyengine/data/qualification/replay_policy.md b/src/policyengine/data/qualification/replay_policy.md new file mode 100644 index 000000000..951be77b6 --- /dev/null +++ b/src/policyengine/data/qualification/replay_policy.md @@ -0,0 +1,55 @@ +# Replay gate policy d1344-replay-regressions-v1 + +Decided under cos d1344 by workstream W4 on 15 October 2026, after two +independent reviews. The pull request that added this file records the +decision, the reviews and the alternatives considered. +`evaluate_replay_receipt` in `policyengine.provenance.qualification` +applies this text. Each qualification receipt records the policy id and +this file's SHA-256 in its `replay` gate details. + +The replay gate fails on the regressions a scorecard replay receipt +identifies. It calls a gap known only on positive evidence from the +comparison base. It sends every other flagged move to a named reviewer. + +## Fail + +- A rejected request, `no_supported_years`, integrity `failed` (a crash or + missing outputs, persistent or not), or a receipt not bound to the + request. +- A mapping flag. +- A lost result: a coverage row with a base value and no candidate value. +- A lost response, unless it is a known gap: + - a measure-year the candidate lists as inert; + - a measure-year total at exactly £0 where the base's absolute value is + at least the diff's bound, max(£1m, 1% of the base's absolute value), + whether the base was positive or negative. +- An event whose supported grid is incomplete, unless every inert or + missing measure-year in it is a known gap. A receipt whose integrity is + `incomplete` with no incomplete event also fails. + +## Known gaps + +A measure-year is a known gap only when the receipt positively records it +as inert, or missing and supported, in the base, for the same event, +measure and year. Until the receipt carries that base-side record, there +are no known gaps. + +## Review + +A needs-review verdict blocks automatic promotion. It stays blocked until a +named reviewer accepts that exact qualification receipt. These go to +review: + +- numerical flags; +- structural flags other than a lost response, including sign reversals + and moves from zero to non-zero; +- construction flags; +- support flags (the separate `supported_years` gate fails a candidate that + loses a year the previous certificate supported); +- coverage gains, coverage rows with no value on either side, and any other + coverage row that is not a lost result; +- inertness flags on a measure-year the candidate does not list as inert; +- a receipt with no diff against a certified variant. + +Unsupported years alone never fail. A move below the diff's bound raises +no flag and is not reviewed. diff --git a/src/policyengine/provenance/qualification.py b/src/policyengine/provenance/qualification.py index 90b6085f9..9ca873400 100644 --- a/src/policyengine/provenance/qualification.py +++ b/src/policyengine/provenance/qualification.py @@ -28,6 +28,7 @@ from __future__ import annotations +import hashlib from collections.abc import Iterable, Mapping, Sequence from typing import Any, ClassVar, Literal, Optional @@ -52,6 +53,7 @@ __all__ = [ "BLOCKING_REPLAY_FLAGS", + "REPLAY_POLICY_ID", "REQUIRED_GATES", "REVIEW_REPLAY_FLAGS", "CandidatePair", @@ -100,12 +102,31 @@ "error": 3, } -#: Replay diff flags that block promotion whatever their size. Proposed -#: policy pending cos d1344; numerical and support flags need review. -BLOCKING_REPLAY_FLAGS: frozenset[str] = frozenset( - {"structural", "coverage", "mapping", "inertness", "construction"} +#: The replay gate's policy (cos d1344): fail on the regressions a receipt +#: identifies, call a gap known only on base-side evidence, and send every +#: other flagged move to a named reviewer. Its text ships as +#: ``data/qualification/replay_policy.md`` and each receipt records its digest. +REPLAY_POLICY_ID = "d1344-replay-regressions-v1" +REPLAY_POLICY_FILE = "replay_policy.md" +#: Flags that fail whatever the row says. +BLOCKING_REPLAY_FLAGS: frozenset[str] = frozenset({"mapping"}) +#: Flags that need review unless the row is a lost result or lost response. +REVIEW_REPLAY_FLAGS: frozenset[str] = frozenset( + {"numerical", "structural", "coverage", "support", "inertness", "construction"} ) -REVIEW_REPLAY_FLAGS: frozenset[str] = frozenset({"numerical", "support"}) +#: The diff's numerical bound: a move needs at least max(this, 1% of the +#: base's absolute value) to be flagged. +REPLAY_ABSOLUTE_BOUND_GBP = 1_000_000.0 +REPLAY_RELATIVE_BOUND = 0.01 + + +def replay_policy_sha256() -> str: + """SHA-256 of the replay policy text this package applies.""" + + from importlib.resources import files + + data = files("policyengine.data.qualification") / REPLAY_POLICY_FILE + return hashlib.sha256(data.read_bytes()).hexdigest() class GateResult(_Contract): @@ -746,18 +767,27 @@ def evaluate_replay_receipt( """The ``replay`` gate for a terminal replay status and its receipt. The receipt must answer this exact request, be the bytes the status - names, and report an execution of exactly the requested artifacts. - Rejected or failed replays, incomplete runs and blocking flags fail the - gate; numerical and support flags need review. Unsupported years are - reported, never failed on their own. + names, and report an execution of exactly the requested artifacts. Its + contents are then judged by policy ``d1344-replay-regressions-v1`` + (``data/qualification/replay_policy.md``): + + - fail on rejected or failed replays, mapping flags, lost results + (a base value with no candidate value), lost responses (a measure-year + the candidate lists as inert, or a measure-year total of exactly £0 + against a base at least the diff's bound away) and incomplete grids, + unless a gap is known from base-side evidence; + - send every other flagged move to review. + + Unsupported years are reported, never failed on their own. The gate's + details record the policy id, the policy text's SHA-256, the known gaps, + the regressions and the review items. The receipt is read as a whole, not by its summary counters: a flag counts when either ``flag_counts`` or a flagged row carries it, and a receipt that contradicts itself fails (failures listed under a complete integrity, more flagged rows of a kind than its count, supported years with no artifacts, nothing replayed at all) or contradicts the request - (a comparison base other than the ``compare_to`` asked for). Inert - measure-years with no inertness flag need review. + (a comparison base other than the ``compare_to`` asked for). """ request_id = request.request_id @@ -901,17 +931,6 @@ def evaluate_replay_receipt( "inert_measure_years": [list(item) for item in inert], } ) - if receipt.integrity.status != "complete" or incomplete_events: - return GateResult( - gate_id="replay", - status="fail", - summary=( - "Replay did not complete every supported measure-year" - + (f" ({', '.join(incomplete_events)})" if incomplete_events else "") - + "; mandatory gates take no partial evidence." - ), - details={**details, "incomplete_events": incomplete_events}, - ) # A receipt whose parts disagree is not evidence of anything. contradictions: list[str] = [] if receipt.integrity.failures: @@ -961,43 +980,122 @@ def evaluate_replay_receipt( details={**details, "contradictions": contradictions}, ) # Flags come from every place the receipt can state them, never from the - # summary counters alone. + # summary counters alone: a flag a counter states but no row carries + # still needs review. present = {kind for kind, n in counts.items() if n} | set(row_counts) - blocking = sorted(present & BLOCKING_REPLAY_FLAGS) - review = sorted(present & REVIEW_REPLAY_FLAGS) - - def tally(kinds: Sequence[str]) -> str: - return ", ".join( - f"{kind} {max(counts.get(kind, 0), row_counts.get(kind, 0))}" - for kind in kinds + # No receipt yet records the base's own inert and missing measure-years, + # so no gap is known and every inert or incomplete measure-year fails. + known_gaps: list[dict[str, Any]] = [] + known = {(gap["event"], gap["measure_key"], gap["year"]) for gap in known_gaps} + listed_inert = set(inert) + candidate_inert = {where for where in listed_inert if where not in known} + regressions: list[dict[str, Any]] = [] + review_items: list[dict[str, Any]] = [] + + def item(kind: str, row: Any = None, **fields: Any) -> dict[str, Any]: + record: dict[str, Any] = {"kind": kind} + if row is not None: + record.update( + event=row.event, + measure_key=row.measure_key, + year=row.year, + level=row.level, + key=row.key, + flags=sorted(row.flags), + base_gbp=row.base_gbp, + head_gbp=row.head_gbp, + ) + record.update(fields) + return record + + for event, measure_key, year in sorted(candidate_inert): + regressions.append( + item( + "lost_response", + event=event, + measure_key=measure_key, + year=year, + why="inert in the candidate", + ) + ) + for name in incomplete_events: + regressions.append( + item( + "incomplete", + event=name, + why="the supported grid is incomplete and no gap in it is known", + ) ) + if receipt.integrity.status == "incomplete" and not incomplete_events: + regressions.append( + item("incomplete", why="integrity is incomplete with no incomplete event") + ) + for row in rows: + flags = set(row.flags) + where = (row.event, row.measure_key, row.year) + lost = False + if flags & BLOCKING_REPLAY_FLAGS: + regressions.append(item("mapping", row)) + lost = True + if "coverage" in flags and row.base_gbp is not None and row.head_gbp is None: + regressions.append(item("lost_result", row)) + lost = True + zero_total = ( + row.level == "measure_year" + and row.head_gbp == 0 + and row.base_gbp is not None + and abs(row.base_gbp) + >= max(REPLAY_ABSOLUTE_BOUND_GBP, REPLAY_RELATIVE_BOUND * abs(row.base_gbp)) + ) + if where in listed_inert and (zero_total or "inertness" in flags): + # The candidate's own inert list decides: a lost response above, + # or a known gap. + lost = True + elif zero_total and where not in known: + regressions.append(item("lost_response", row, why="£0 against the base")) + lost = True + if not lost and flags & REVIEW_REPLAY_FLAGS: + if "coverage" in flags and row.base_gbp is None: + kind = ( + "coverage_gain" + if row.head_gbp is not None + else "coverage_without_values" + ) + elif flags == {"inertness"}: + kind = "base_only_inertness" + else: + kind = "flagged_move" + review_items.append(item(kind, row)) + for kind in sorted(present - row_counts.keys()): + review_items.append(item("counted_flag", flag=kind, count=counts.get(kind, 0))) + details.update( + { + "policy": REPLAY_POLICY_ID, + "policy_sha256": replay_policy_sha256(), + "known_gaps": known_gaps, + "regressions": regressions, + "review_items": review_items, + } + ) - if blocking: + def tally(records: Sequence[dict[str, Any]]) -> str: + kinds: dict[str, int] = {} + for record in records: + kinds[record["kind"]] = kinds.get(record["kind"], 0) + 1 + return ", ".join(f"{kind} {n}" for kind, n in sorted(kinds.items())) + + if regressions: return GateResult( gate_id="replay", status="fail", - summary="Blocking replay flags: " + tally(blocking), + summary="Replay regressions: " + tally(regressions), details=details, ) - if review: + if review_items: return GateResult( gate_id="replay", status="needs_review", - summary="Replay moves need review: " + tally(review), - details=details, - ) - if inert: - # A measure that does nothing in a supported year, with no inertness - # flag against the base: unchanged from the base, but still a measure - # the model does not respond to. A person decides whether it is known. - return GateResult( - gate_id="replay", - status="needs_review", - summary=( - f"{len(inert)} replayed measure-year(s) are inert, with no " - "inertness flag against the base: " - + ", ".join(f"{m} {y}" for _, m, y in inert[:6]) - ), + summary="Replay moves need review: " + tally(review_items), details=details, ) if receipt.diff is None: diff --git a/tests/fixtures/uk_replay/README.md b/tests/fixtures/uk_replay/README.md index 398abb5c9..3c15e2c82 100644 --- a/tests/fixtures/uk_replay/README.md +++ b/tests/fixtures/uk_replay/README.md @@ -8,3 +8,10 @@ replay interface. The scorecard computes digests with `rfc8785==0.1.4`; Refresh by copying the files from a newer scorecard commit and updating the commit above; never edit them by hand. + +`receipt_6.4.1_attempt1/` holds the first real replay request and receipt, for +the pair policyengine 6.4.1, policyengine-uk 2.130.1 and uk-data 1.58.1. They +were copied unchanged from the scorecard's `replay-state` branch at commit +`4166508f` (`requests//`). The receipt holds aggregate fiscal +totals only. `tests/test_qualification_contracts.py` checks that the replay +policy fails it on exactly what the d1344 decision says it should. diff --git a/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REPLAY_RECEIPT.json b/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REPLAY_RECEIPT.json new file mode 100644 index 000000000..b265bfb54 --- /dev/null +++ b/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REPLAY_RECEIPT.json @@ -0,0 +1,4480 @@ +{ + "corpus": { + "corpus_version": "uk-fiscal-event-replay/1", + "events": { + "autumn_budget_2024": { + "artifacts": 30, + "comparison_provenance_sha256": "892ef8cf5e66c21d91d96a344236fb51071b7e43d885bb489c3eed0bdeb5f390", + "full_supported_grid_complete": true, + "inert_measure_years": [], + "registry_sha256": "89784d34920fb2d6aae702fb4028f84d7bf87f1694df77a0c9c5d255edbcdd34", + "run_manifest_sha256": "593a3e37a69faf3c5c4afeda26375aba2e748c208b8193178f6fbef179da6761", + "staging_manifest_sha256": "e9abe69562c9f8c0ccc2973331acb66d5b7d2971be7a22d257fb3a64057f8afb", + "supported_years": [ + 2024, + 2025, + 2026, + 2027, + 2028, + 2029 + ], + "unsupported_years": [] + }, + "autumn_statement_2023": { + "artifacts": 20, + "comparison_provenance_sha256": "f3b7f083abea6eaeb584964b5e677ffc49ea1b2ad2e96a023453a25f7f0e6f3e", + "full_supported_grid_complete": true, + "inert_measure_years": [], + "registry_sha256": "197d5583f0171104c673ca1f40b402a77ca2b003f496de37d6bc913fb9fd1021", + "run_manifest_sha256": "debb2ffec8011fe577e57ef3e334f72aa0e50d90ab46a8536ab2c231c2259c31", + "staging_manifest_sha256": "f2ed6a429597e7bc42e46398cdb03f69d92511c266762c4bfde6d00cf4beae08", + "supported_years": [ + 2024, + 2025, + 2026, + 2027, + 2028 + ], + "unsupported_years": [ + { + "reason": "before_data_year", + "year": 2023 + } + ] + }, + "spring_budget_2023": { + "artifacts": 8, + "comparison_provenance_sha256": "c2beddb0bafb0c7a92ec20ed8a061acd1012f4cced9c3776b0abcb95bdd0ba4d", + "full_supported_grid_complete": true, + "inert_measure_years": [], + "registry_sha256": "0a78bfe32712bd30bdf9137bdf182e82a266c7da0e9885dd61cac6ef99a76357", + "run_manifest_sha256": "e242bf40a01f186fd6b7c759d8d3927058a5a9e382d08d48df618f28199a6d64", + "staging_manifest_sha256": "7a74c574a781e37e45d5adb05afc694b4ba3fb1665fb45f5630c063ac75bec51", + "supported_years": [ + 2024, + 2025, + 2026, + 2027 + ], + "unsupported_years": [ + { + "reason": "before_data_year", + "year": 2023 + } + ] + }, + "spring_budget_2024": { + "artifacts": 15, + "comparison_provenance_sha256": "58478003451a69686b08c5bc90d8937f9614c5a19b384e0ea735e59d9cd4d950", + "full_supported_grid_complete": true, + "inert_measure_years": [], + "registry_sha256": "64a3bccfd950e868ffc5d6261bf3903df88a3ebedc9b4444494acc717259817a", + "run_manifest_sha256": "0bce7f6e84de2cec8305ecfc8e769cd2c79e73ec736feb8282e9adaf944a4592", + "staging_manifest_sha256": "161ae8d04e51c4180d1c78ccd5fd21d0fae410f4d9237228bd7c2d9e61b90d37", + "supported_years": [ + 2024, + 2025, + 2026, + 2027, + 2028 + ], + "unsupported_years": [ + { + "reason": "before_data_year", + "year": 2023 + } + ] + }, + "spring_statement_2025": { + "artifacts": 12, + "comparison_provenance_sha256": "93a00d8930bc4cac1fbdb6986fd9bdc718fff5d0fd302df2aa07488fa8f10327", + "full_supported_grid_complete": false, + "inert_measure_years": [ + { + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2026 + }, + { + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2027 + }, + { + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2028 + }, + { + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2029 + } + ], + "registry_sha256": "d01ce0612f3f24830a8d8ea0abaaa6b2626963316b16750310d303f404fbab5b", + "run_manifest_sha256": "da4c040fee52a6338eba98d0b9d9f8ad88884df8ec132b4264c74c66a5a3ae50", + "staging_manifest_sha256": "d55ee087c5922f63badb8d55c888f7f6a469b6185950cdb585a5ae7eebb7b20c", + "supported_years": [ + 2024, + 2025, + 2026, + 2027, + 2028, + 2029 + ], + "unsupported_years": [] + } + } + }, + "diff": { + "base_variant_id": "pe5.0.2_uk2.89.2_populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "diff_sha256": "b05a9a5fa31ee15fa34a33dcf52502d11fcd716a5892388124ea3504f0c5b180", + "flag_counts": { + "construction": 6, + "coverage": 1, + "inertness": 8, + "mapping": 0, + "numerical": 312, + "structural": 25, + "support": 34 + }, + "flagged_rows": [ + { + "base_gbp": null, + "event": "autumn_budget_2024", + "flags": [ + "coverage" + ], + "head_gbp": 574999000.021347, + "key": "obr_pmd_nov2025:11682:73295721bdfdb9e8", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2024 + }, + { + "base_gbp": 0.0, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "structural", + "construction" + ], + "head_gbp": 574999000.021347, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2024 + }, + { + "base_gbp": 0.0, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": 574999000.021347, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2024 + }, + { + "base_gbp": 4655334129.036697, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2321626476.090164, + "key": "obr_pmd_nov2025:11683:b9096ecd110093bf", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2025 + }, + { + "base_gbp": 4655334129.036697, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "construction" + ], + "head_gbp": 2321626476.090164, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2025 + }, + { + "base_gbp": 4655334129.036697, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2321626476.090164, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2025 + }, + { + "base_gbp": 4793563953.670055, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2397857290.327587, + "key": "obr_pmd_nov2025:11684:0d39628e9b763f67", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2026 + }, + { + "base_gbp": 4793563953.670055, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "construction" + ], + "head_gbp": 2397857290.327587, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2026 + }, + { + "base_gbp": 4793563953.670055, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2397857290.327587, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2026 + }, + { + "base_gbp": 4952429950.859825, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2484671863.66201, + "key": "obr_pmd_nov2025:11685:440412b0fb8c6e2d", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2027 + }, + { + "base_gbp": 4952429950.859825, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "construction" + ], + "head_gbp": 2484671863.66201, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2027 + }, + { + "base_gbp": 4952429950.859825, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2484671863.66201, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2027 + }, + { + "base_gbp": 5112968146.86298, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2572403121.83226, + "key": "obr_pmd_nov2025:11686:9ceb140875d859b8", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2028 + }, + { + "base_gbp": 5112968146.86298, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "construction" + ], + "head_gbp": 2572403121.83226, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2028 + }, + { + "base_gbp": 5112968146.86298, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2572403121.83226, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2028 + }, + { + "base_gbp": 5273812739.539009, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2660288740.9552917, + "key": "obr_pmd_nov2025:11687:f738e039412d9872", + "level": "source_row", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2029 + }, + { + "base_gbp": 5273812739.539009, + "event": "autumn_budget_2024", + "flags": [ + "numerical", + "construction" + ], + "head_gbp": 2660288740.9552917, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2029 + }, + { + "base_gbp": 5273812739.539009, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2660288740.9552917, + "key": "capital_gains_tax", + "level": "head", + "measure_key": "autumn_budget_2024__capital_gains_main_rates_and_reliefs", + "year": 2029 + }, + { + "base_gbp": -8163494973.898804, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7061749331.279724, + "key": "obr_pmd_nov2025:11665:72598886f546ef8c", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2025 + }, + { + "base_gbp": 24174758043.215755, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 23874647882.154022, + "key": "obr_pmd_nov2025:11671:291070a94715062c", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2025 + }, + { + "base_gbp": 16011263069.316952, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 16812898550.874298, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2025 + }, + { + "base_gbp": -8163494973.898804, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7061749331.279724, + "key": "income_tax", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2025 + }, + { + "base_gbp": -1251612208.7499237, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1429307494.3798141, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2025 + }, + { + "base_gbp": -8507737534.679443, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7376939848.9661255, + "key": "obr_pmd_nov2025:11666:e4413c7487d85ddf", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2026 + }, + { + "base_gbp": 24754424875.897583, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 24413340740.52112, + "key": "obr_pmd_nov2025:11672:0b55202125025854", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2026 + }, + { + "base_gbp": 16246687341.218138, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 17036400891.554995, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2026 + }, + { + "base_gbp": -8507737534.679443, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7376939848.9661255, + "key": "income_tax", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2026 + }, + { + "base_gbp": -1260499988.3872528, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1431638070.3618393, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2026 + }, + { + "base_gbp": -8783977312.67981, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7564046836.411194, + "key": "obr_pmd_nov2025:11667:35345df3785dfdea", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2027 + }, + { + "base_gbp": 25206449240.09022, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 24832078611.256916, + "key": "obr_pmd_nov2025:11673:94b8906a8929f113", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2027 + }, + { + "base_gbp": 16422471927.410412, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 17268031774.845722, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2027 + }, + { + "base_gbp": -8783977312.67981, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7564046836.411194, + "key": "income_tax", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2027 + }, + { + "base_gbp": -1264763359.1383667, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1433904842.3321228, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2027 + }, + { + "base_gbp": -8988426278.909851, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7898227103.010376, + "key": "obr_pmd_nov2025:11668:a076a7d6b3992e7c", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2028 + }, + { + "base_gbp": 25618300125.581856, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 25217865979.05774, + "key": "obr_pmd_nov2025:11674:6dd4d473c559cbea", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2028 + }, + { + "base_gbp": 16629873846.672005, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 17319638876.047363, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2028 + }, + { + "base_gbp": -8988426278.909851, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -7898227103.010376, + "key": "income_tax", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2028 + }, + { + "base_gbp": -1282042489.6673584, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1446313392.5655975, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2028 + }, + { + "base_gbp": -9404061793.93042, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -8092876935.234375, + "key": "obr_pmd_nov2025:11669:1cedd72c387ad84b", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2029 + }, + { + "base_gbp": 26148779059.003807, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 25792458746.42136, + "key": "obr_pmd_nov2025:11675:286359ea5a53fc94", + "level": "source_row", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2029 + }, + { + "base_gbp": 16744717265.073387, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 17699581811.186985, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2029 + }, + { + "base_gbp": -9404061793.93042, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -8092876935.234375, + "key": "income_tax", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2029 + }, + { + "base_gbp": -1298741997.8063965, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1450619444.7683945, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_budget_2024__employer_nics_package", + "year": 2029 + }, + { + "base_gbp": 1258445339.7229428, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1940272255.14898, + "key": "obr_pmd_nov2025:11617:414a87b10621e433", + "level": "source_row", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2025 + }, + { + "base_gbp": 1258445339.7229428, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1940272255.14898, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2025 + }, + { + "base_gbp": 1258445339.7229428, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1940272255.14898, + "key": "private_school_vat", + "level": "head", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2025 + }, + { + "base_gbp": 1292278978.386702, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1966545651.307916, + "key": "obr_pmd_nov2025:11618:ad4256274cc146d3", + "level": "source_row", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2026 + }, + { + "base_gbp": 1292278978.386702, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1966545651.307916, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2026 + }, + { + "base_gbp": 1292278978.386702, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1966545651.307916, + "key": "private_school_vat", + "level": "head", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2026 + }, + { + "base_gbp": 1322999530.8546522, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2044395945.9878988, + "key": "obr_pmd_nov2025:11619:8726edda9aa7b020", + "level": "source_row", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2027 + }, + { + "base_gbp": 1322999530.8546522, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2044395945.9878988, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2027 + }, + { + "base_gbp": 1322999530.8546522, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2044395945.9878988, + "key": "private_school_vat", + "level": "head", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2027 + }, + { + "base_gbp": 1354856060.444217, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2138101665.0296612, + "key": "obr_pmd_nov2025:11620:5446e6157f4ef4ec", + "level": "source_row", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2028 + }, + { + "base_gbp": 1354856060.444217, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2138101665.0296612, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2028 + }, + { + "base_gbp": 1354856060.444217, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2138101665.0296612, + "key": "private_school_vat", + "level": "head", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2028 + }, + { + "base_gbp": 1388030829.5242352, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2163020750.92759, + "key": "obr_pmd_nov2025:11621:8bf103a18fecf781", + "level": "source_row", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2029 + }, + { + "base_gbp": 1388030829.5242352, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2163020750.92759, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2029 + }, + { + "base_gbp": 1388030829.5242352, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2163020750.92759, + "key": "private_school_vat", + "level": "head", + "measure_key": "autumn_budget_2024__private_school_vat_20pct", + "year": 2029 + }, + { + "base_gbp": 2039004728.2579594, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2182277423.2901382, + "key": "obr_pmd_nov2025:11647:b269082735adb83b", + "level": "source_row", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2025 + }, + { + "base_gbp": 2039004728.2579594, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2182277423.2901382, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2025 + }, + { + "base_gbp": 2039004728.2579594, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2182277423.2901382, + "key": "stamp_duty_land_tax", + "level": "head", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2025 + }, + { + "base_gbp": 2106518390.8636017, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2254534269.309538, + "key": "obr_pmd_nov2025:11648:48bd77ddd06dec27", + "level": "source_row", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2026 + }, + { + "base_gbp": 2106518390.8636017, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2254534269.309538, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2026 + }, + { + "base_gbp": 2106518390.8636017, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2254534269.309538, + "key": "stamp_duty_land_tax", + "level": "head", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2026 + }, + { + "base_gbp": 2182605140.493597, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2335967764.6855392, + "key": "obr_pmd_nov2025:11649:910077d14a180f90", + "level": "source_row", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2027 + }, + { + "base_gbp": 2182605140.493597, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2335967764.6855392, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2027 + }, + { + "base_gbp": 2182605140.493597, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2335967764.6855392, + "key": "stamp_duty_land_tax", + "level": "head", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2027 + }, + { + "base_gbp": 2259267108.0862694, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2418086716.0288925, + "key": "obr_pmd_nov2025:11650:068acce8b0873200", + "level": "source_row", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2028 + }, + { + "base_gbp": 2259267108.0862694, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2418086716.0288925, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2028 + }, + { + "base_gbp": 2259267108.0862694, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2418086716.0288925, + "key": "stamp_duty_land_tax", + "level": "head", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2028 + }, + { + "base_gbp": 2336375935.3248425, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2500617321.3659096, + "key": "obr_pmd_nov2025:11651:9a7327566a66890c", + "level": "source_row", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2029 + }, + { + "base_gbp": 2336375935.3248425, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2500617321.3659096, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2029 + }, + { + "base_gbp": 2336375935.3248425, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2500617321.3659096, + "key": "stamp_duty_land_tax", + "level": "head", + "measure_key": "autumn_budget_2024__sdlt_additional_dwelling_surcharge_2pp", + "year": 2029 + }, + { + "base_gbp": 1713880895.879209, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1477704209.4028206, + "key": "obr_pmd_nov2025:22853:636080d8aa33dd8e", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2024 + }, + { + "base_gbp": 1713880895.879209, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1477704209.4028206, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2024 + }, + { + "base_gbp": 1713880895.879209, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 1477704209.4028206, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2024 + }, + { + "base_gbp": 349882534.4296024, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 175460925.4459455, + "key": "obr_pmd_nov2025:22854:edbde987f66af165", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2025 + }, + { + "base_gbp": 349882534.4296024, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 175460925.4459455, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2025 + }, + { + "base_gbp": 349882534.4296024, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 175460925.4459455, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2025 + }, + { + "base_gbp": 367920017.09756255, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 184684987.91397357, + "key": "obr_pmd_nov2025:22855:dbc5baf2a54734dc", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2026 + }, + { + "base_gbp": 367920017.09756255, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 184684987.91397357, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2026 + }, + { + "base_gbp": 367920017.09756255, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 184684987.91397357, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2026 + }, + { + "base_gbp": 397324703.9610295, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 216206005.17124224, + "key": "obr_pmd_nov2025:22856:c29981419df6c6f6", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2027 + }, + { + "base_gbp": 397324703.9610295, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 216206005.17124224, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2027 + }, + { + "base_gbp": 397324703.9610295, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 216206005.17124224, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2027 + }, + { + "base_gbp": 417582943.08061886, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 217874757.0607648, + "key": "obr_pmd_nov2025:22857:92bf18d3bd843235", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2028 + }, + { + "base_gbp": 417582943.08061886, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 217874757.0607648, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2028 + }, + { + "base_gbp": 417582943.08061886, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 217874757.0607648, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2028 + }, + { + "base_gbp": 453160743.2087734, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 228829386.94062686, + "key": "obr_pmd_nov2025:22858:6050e74df71edc1c", + "level": "source_row", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2029 + }, + { + "base_gbp": 453160743.2087734, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 228829386.94062686, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2029 + }, + { + "base_gbp": 453160743.2087734, + "event": "autumn_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 228829386.94062686, + "key": "winter_fuel_allowance", + "level": "head", + "measure_key": "autumn_budget_2024__winter_fuel_means_test", + "year": 2029 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10542:89ab4668e579ad56", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10548:f2f563200611f223", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10560:3a6d16cff489f44c", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:21461:f9e31391303b473d", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2023 + }, + { + "base_gbp": -11082510181.993792, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -10630356167.505722, + "key": "obr_pmd_nov2025:10549:7322b894d39fa3ec", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": 86201088.67014313, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 241090644.10290527, + "key": "obr_pmd_nov2025:21462:c775903b43caf723", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": -10996309093.323648, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -10389265523.402817, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": -11086458169.228767, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -10632796331.321083, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": 3947987.234975338, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 2440163.8153615, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": 86201088.67014313, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 241090644.10290527, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2024 + }, + { + "base_gbp": -11685772883.678026, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11329957617.157879, + "key": "obr_pmd_nov2025:10550:68081ae97974ee1e", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2025 + }, + { + "base_gbp": 94249253.54277039, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 266181343.4298706, + "key": "obr_pmd_nov2025:21463:fd9bc11d19465ecb", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2025 + }, + { + "base_gbp": -11591523630.135256, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11063776273.728008, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2025 + }, + { + "base_gbp": -11689444668.923279, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11332885657.203163, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2025 + }, + { + "base_gbp": 94249253.54277039, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 266181343.4298706, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2025 + }, + { + "base_gbp": -12070073648.808764, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11791691029.942415, + "key": "obr_pmd_nov2025:10551:67fe35e4947b995a", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2026 + }, + { + "base_gbp": 104678630.74134064, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 282034438.7785034, + "key": "obr_pmd_nov2025:21464:190052623cdf04c0", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2026 + }, + { + "base_gbp": -11965395018.067423, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11509656591.163912, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2026 + }, + { + "base_gbp": -12073479284.911896, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11795774735.615051, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2026 + }, + { + "base_gbp": 104678630.74134064, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 282034438.7785034, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2026 + }, + { + "base_gbp": -12356159334.317142, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -12126516751.307087, + "key": "obr_pmd_nov2025:10552:16a41c96f88267ae", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": 109333370.59695435, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 292445236.05249023, + "key": "obr_pmd_nov2025:21465:6ea19652f3e3ea6b", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": -12246825963.720188, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -11834071515.254597, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": -12359540300.262665, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -12132335398.171082, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": 3380965.9455218315, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 5818646.863994598, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": 109333370.59695435, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 292445236.05249023, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2027 + }, + { + "base_gbp": -12663862924.5513, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -12442433002.339989, + "key": "obr_pmd_nov2025:10553:39c2cf71f48d4440", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": 109787606.17053986, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 303440070.03996277, + "key": "obr_pmd_nov2025:21466:43674024394e1bc0", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": -12554075318.38076, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -12138992932.300026, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": -12667318767.890686, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -12449876132.117287, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": 3455843.3393855095, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 7443129.777297974, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": 109787606.17053986, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 303440070.03996277, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_1_employee_nics_main_rate_cut_2p", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10590:9fb656d369cf6c02", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10596:482eac698ab9c181", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10608:8d76207d856fc00c", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:21497:197a81ed0459bc7d", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2023 + }, + { + "base_gbp": -512170359.34750974, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -444404910.7621303, + "key": "obr_pmd_nov2025:10597:37b420855b7005a0", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": 9706978.086303711, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 39312296.15826416, + "key": "obr_pmd_nov2025:21498:3fa4108a7eac0a15", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": -502463381.26120603, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -405092614.60386616, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": -512118439.03477466, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -444404910.7621303, + "key": "ni_class_2", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": -51920.31273508072, + "event": "autumn_statement_2023", + "flags": [ + "structural" + ], + "head_gbp": 0.0, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": 9706978.086303711, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 39312296.15826416, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2024 + }, + { + "base_gbp": -519186159.4330233, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -459264249.43539166, + "key": "obr_pmd_nov2025:10598:28f9366b82391330", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": 9353628.809425354, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 39489133.344177246, + "key": "obr_pmd_nov2025:21499:909bf2ab3554e230", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": -509832530.6235979, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -419775116.0912144, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": -519141267.9473098, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -459264249.43539166, + "key": "ni_class_2", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": -44891.4857134819, + "event": "autumn_statement_2023", + "flags": [ + "structural" + ], + "head_gbp": 0.0, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": 9353628.809425354, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 39489133.344177246, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2025 + }, + { + "base_gbp": -542530999.0906498, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -454992560.1827409, + "key": "obr_pmd_nov2025:10599:0cdd3c6417e28a14", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": 11441880.627342224, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 37503348.28427124, + "key": "obr_pmd_nov2025:21500:3cd35347ee5fc266", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": -531089118.4633076, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -417489211.8984697, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": -542485937.01707, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -454992560.1827409, + "key": "ni_class_2", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": -45062.07357978821, + "event": "autumn_statement_2023", + "flags": [ + "structural" + ], + "head_gbp": 0.0, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": 11441880.627342224, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 37503348.28427124, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2026 + }, + { + "base_gbp": -1338847352.1526794, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -474097034.6309568, + "key": "obr_pmd_nov2025:10600:d0828a5bfb537ca4", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": 11933081.293174744, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 41829053.17868042, + "key": "obr_pmd_nov2025:21501:53e1c92d4a4f4e96", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": -1326914270.8595047, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -432267981.4522764, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": -552039153.5443193, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -474097034.6309568, + "key": "ni_class_2", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": -786808198.6083603, + "event": "autumn_statement_2023", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": 0.0, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": 11933081.293174744, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 41829053.17868042, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2027 + }, + { + "base_gbp": -1054555794.4022624, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -491167651.8830828, + "key": "obr_pmd_nov2025:10601:756ded1466aa84b3", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": 11951206.26462555, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 41993236.97143555, + "key": "obr_pmd_nov2025:21502:1ea7fa7e0cfb40da", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": -1042604588.1376369, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -449174414.91164726, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": -558680740.925205, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -491167651.8830828, + "key": "ni_class_2", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": -495875053.47705746, + "event": "autumn_statement_2023", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": 0.0, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": 11951206.26462555, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 41993236.97143555, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_2_self_employed_nics_abolition", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10566:0e8d21e52f88eb57", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10572:2ff1949c7ce7acb3", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10584:443a5169e8191f85", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:21479:4eea71bff070729b", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2023 + }, + { + "base_gbp": -557157541.6621337, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -324495750.0986695, + "key": "obr_pmd_nov2025:10573:22a57b73322dcf42", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2024 + }, + { + "base_gbp": 6461029.071586609, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 18655108.79447937, + "key": "obr_pmd_nov2025:21480:4e24d4d5f036370a", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2024 + }, + { + "base_gbp": -550696512.5905471, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -305840641.30419016, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2024 + }, + { + "base_gbp": -557157541.6621337, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -324495750.0986695, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2024 + }, + { + "base_gbp": 6461029.071586609, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 18655108.79447937, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2024 + }, + { + "base_gbp": -564695883.8826604, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -330653144.5776048, + "key": "obr_pmd_nov2025:10574:46d6b72964d38975", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2025 + }, + { + "base_gbp": 6347881.413131714, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 20918336.49822998, + "key": "obr_pmd_nov2025:21481:2b2973b0ac23d6e9", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2025 + }, + { + "base_gbp": -558348002.4695287, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -309734808.0793748, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2025 + }, + { + "base_gbp": -564695883.8826604, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -330653144.5776048, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2025 + }, + { + "base_gbp": 6347881.413131714, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 20918336.49822998, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2025 + }, + { + "base_gbp": -583538584.2492275, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -348326831.56943035, + "key": "obr_pmd_nov2025:10575:e617707f9625fbba", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2026 + }, + { + "base_gbp": 7701561.54548645, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 22048259.627441406, + "key": "obr_pmd_nov2025:21482:753c71d52bde5307", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2026 + }, + { + "base_gbp": -575837022.7037411, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -326278571.94198895, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2026 + }, + { + "base_gbp": -583538584.2492275, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -348326831.56943035, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2026 + }, + { + "base_gbp": 7701561.54548645, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 22048259.627441406, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2026 + }, + { + "base_gbp": -607032565.8024693, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -369477043.1628766, + "key": "obr_pmd_nov2025:10576:033898ebb4865f78", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2027 + }, + { + "base_gbp": 8119534.424377441, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 23584034.71257019, + "key": "obr_pmd_nov2025:21483:8913a74a278eaeb2", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2027 + }, + { + "base_gbp": -598913031.3780918, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -345893008.4503064, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2027 + }, + { + "base_gbp": -607032565.8024693, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -369477043.1628766, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2027 + }, + { + "base_gbp": 8119534.424377441, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 23584034.71257019, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2027 + }, + { + "base_gbp": -631251667.9559898, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -390706828.95479107, + "key": "obr_pmd_nov2025:10577:55cd081c955ca847", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2028 + }, + { + "base_gbp": 8283750.790786743, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 24746395.838638306, + "key": "obr_pmd_nov2025:21484:a26658ab9b55efac", + "level": "source_row", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2028 + }, + { + "base_gbp": -622967917.1652031, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -365960433.11615276, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2028 + }, + { + "base_gbp": -631251667.9559898, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -390706828.95479107, + "key": "ni_class_4", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2028 + }, + { + "base_gbp": 8283750.790786743, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": 24746395.838638306, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__class_4_self_employed_nics_main_rate_cut_1p", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:21629:a71ef831d6e1cb87", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2023 + }, + { + "base_gbp": null, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:21635:7e6b2c17ec9be5e4", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "autumn_statement_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2023 + }, + { + "base_gbp": -584567128.8834467, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1687110967.884717, + "key": "obr_pmd_nov2025:21630:bf9643f47e057983", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2024 + }, + { + "base_gbp": -584567128.8834467, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1687110967.884717, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2024 + }, + { + "base_gbp": -35532741.909638405, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -132222067.57011127, + "key": "housing_benefit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2024 + }, + { + "base_gbp": -549034386.9738083, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1554888900.3146057, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2024 + }, + { + "base_gbp": -799767145.6307344, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1883075660.240798, + "key": "obr_pmd_nov2025:21631:75b9415771e2e20c", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2025 + }, + { + "base_gbp": -799767145.6307344, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1883075660.240798, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2025 + }, + { + "base_gbp": -74558905.50565815, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -145070938.0732565, + "key": "housing_benefit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2025 + }, + { + "base_gbp": -725208240.1250763, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1738004722.1675415, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2025 + }, + { + "base_gbp": -833673303.0592041, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1917164100.9136887, + "key": "obr_pmd_nov2025:21632:353c5d3f922bfc89", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2026 + }, + { + "base_gbp": -833673303.0592041, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1917164100.9136887, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2026 + }, + { + "base_gbp": -79044897.4950409, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -149931822.89990997, + "key": "housing_benefit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2026 + }, + { + "base_gbp": -754628405.5641632, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1767232278.0137787, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2026 + }, + { + "base_gbp": -859073219.5275888, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1979148274.9173164, + "key": "obr_pmd_nov2025:21633:5ce95c1559b8bc39", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2027 + }, + { + "base_gbp": -859073219.5275888, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1979148274.9173164, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2027 + }, + { + "base_gbp": -83591867.4580164, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -163561592.63858414, + "key": "housing_benefit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2027 + }, + { + "base_gbp": -775481352.0695724, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1815586682.2787323, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2027 + }, + { + "base_gbp": -859833233.0249033, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -2000162838.6384335, + "key": "obr_pmd_nov2025:21634:ff94f5b54b89d1ee", + "level": "source_row", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2028 + }, + { + "base_gbp": -859833233.0249033, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -2000162838.6384335, + "key": "total", + "level": "measure_year", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2028 + }, + { + "base_gbp": -86662248.82321262, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -169179610.90740013, + "key": "housing_benefit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2028 + }, + { + "base_gbp": -773170984.2016907, + "event": "autumn_statement_2023", + "flags": [ + "numerical" + ], + "head_gbp": -1830983227.7310333, + "key": "universal_credit", + "level": "head", + "measure_key": "autumn_statement_2023__lha_reset_to_30th_percentile", + "year": 2028 + }, + { + "base_gbp": -3693209247.487488, + "event": "spring_budget_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10231:ce0a1472426c8430", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10237:2f261725299509e1", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2023 + }, + { + "base_gbp": -3693209247.487488, + "event": "spring_budget_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2023 + }, + { + "base_gbp": -4297546474.76123, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3004426501.3733215, + "key": "obr_pmd_nov2025:10232:aa24b205227c35bd", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2024 + }, + { + "base_gbp": -4297546474.76123, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3004426501.3733215, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2024 + }, + { + "base_gbp": -4297546474.76123, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3004426501.3733215, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2024 + }, + { + "base_gbp": -5395125608.308472, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3300744216.6347656, + "key": "obr_pmd_nov2025:10233:f9cad5be5ec138c8", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2025 + }, + { + "base_gbp": -5395125608.308472, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3300744216.6347656, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2025 + }, + { + "base_gbp": -5395125608.308472, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3300744216.6347656, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2025 + }, + { + "base_gbp": -6040729276.933105, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3540262930.7841797, + "key": "obr_pmd_nov2025:10234:7fb22cdd33e49932", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2026 + }, + { + "base_gbp": -6040729276.933105, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3540262930.7841797, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2026 + }, + { + "base_gbp": -6040729276.933105, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3540262930.7841797, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2026 + }, + { + "base_gbp": -6456452398.171143, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3725202663.6395264, + "key": "obr_pmd_nov2025:10235:fbb8ccda250b78e9", + "level": "source_row", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2027 + }, + { + "base_gbp": -6456452398.171143, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3725202663.6395264, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2027 + }, + { + "base_gbp": -6456452398.171143, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -3725202663.6395264, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2023__pension_annual_allowance_package", + "year": 2027 + }, + { + "base_gbp": -2321120.9429092407, + "event": "spring_budget_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:20832:882117c50af13444", + "level": "source_row", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2023 + }, + { + "base_gbp": -2321120.9429092407, + "event": "spring_budget_2023", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2023 + }, + { + "base_gbp": -1793717.8464126587, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -239031104.5414734, + "key": "obr_pmd_nov2025:20833:270c17d92e54dca4", + "level": "source_row", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2024 + }, + { + "base_gbp": -1793717.8464126587, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -239031104.5414734, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2024 + }, + { + "base_gbp": -1793717.8464126587, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -239031104.5414734, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2024 + }, + { + "base_gbp": -1723210.1202392578, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -251689860.3839264, + "key": "obr_pmd_nov2025:20834:e0b34fcdbf83f61a", + "level": "source_row", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2025 + }, + { + "base_gbp": -1723210.1202392578, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -251689860.3839264, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2025 + }, + { + "base_gbp": -1723210.1202392578, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -251689860.3839264, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2025 + }, + { + "base_gbp": -1925531.0124588013, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -255168633.24157715, + "key": "obr_pmd_nov2025:20835:1e730fccbdf4c015", + "level": "source_row", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2026 + }, + { + "base_gbp": -1925531.0124588013, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -255168633.24157715, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2026 + }, + { + "base_gbp": -1925531.0124588013, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -255168633.24157715, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2026 + }, + { + "base_gbp": -2069178.6504898071, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -266037263.24046326, + "key": "obr_pmd_nov2025:20836:76beca3818ed1fe1", + "level": "source_row", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2027 + }, + { + "base_gbp": -2069178.6504898071, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -266037263.24046326, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2027 + }, + { + "base_gbp": -2069178.6504898071, + "event": "spring_budget_2023", + "flags": [ + "numerical" + ], + "head_gbp": -266037263.24046326, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2027 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10950:cfbd201bdcc71ff6", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10956:fe78e076cc7bb65b", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:22169:cfddc3a6fdeecca3", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": -11082510181.993792, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -10630356167.505722, + "key": "obr_pmd_nov2025:10957:34718787787f7089", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": 86201088.67014313, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 241090644.10290527, + "key": "obr_pmd_nov2025:22170:45627d3976bd492a", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -10996309093.323648, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -10389265523.402817, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -11086458169.228767, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -10632796331.321083, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": 3947987.234975338, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 2440163.8153615, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": 86201088.67014313, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 241090644.10290527, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -11685772883.678026, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11329957617.157879, + "key": "obr_pmd_nov2025:10958:c7a912de7a1a9114", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": 94249253.54277039, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 266181343.4298706, + "key": "obr_pmd_nov2025:22171:93d8943dfe121528", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -11591523630.135256, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11063776273.728008, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -11689444668.923279, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11332885657.203163, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": 94249253.54277039, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 266181343.4298706, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -12070073648.808764, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11791691029.942415, + "key": "obr_pmd_nov2025:10959:424db47edb88e339", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": 104678630.74134064, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 282034438.7785034, + "key": "obr_pmd_nov2025:22172:8c021f0cf80fe71b", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -11965395018.067423, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11509656591.163912, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -12073479284.911896, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11795774735.615051, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": 104678630.74134064, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 282034438.7785034, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -12356159334.317142, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -12126516751.307087, + "key": "obr_pmd_nov2025:10960:fa02958b7f130d94", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": 109333370.59695435, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 292445236.05249023, + "key": "obr_pmd_nov2025:22173:3beb0f8e7601b1e2", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -12246825963.720188, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -11834071515.254597, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -12359540300.262665, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -12132335398.171082, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": 3380965.9455218315, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 5818646.863994598, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": 109333370.59695435, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 292445236.05249023, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -12663862924.5513, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -12442433002.339989, + "key": "obr_pmd_nov2025:10961:cc058843879051af", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 109787606.17053986, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 303440070.03996277, + "key": "obr_pmd_nov2025:22174:b9088212717e5ab4", + "level": "source_row", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": -12554075318.38076, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -12138992932.300026, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": -12667318767.890686, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -12449876132.117287, + "key": "ni_class_1_employee", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 3455843.3393855095, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 7443129.777297974, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 109787606.17053986, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 303440070.03996277, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_1_employee_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10968:f0c2d5c4f10ba12c", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10974:fdd0e608076292a8", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:22187:cc8ffca6f2f5e590", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2023 + }, + { + "base_gbp": -1114763387.7151809, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -649587042.2363234, + "key": "obr_pmd_nov2025:10975:497e18b9ba827fa3", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": 12922100.778694153, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 37366356.46134949, + "key": "obr_pmd_nov2025:22188:9f98363a90ba075c", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -1101841286.9364867, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -612220685.7749739, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -1114763387.7151809, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -649587042.2363234, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": 12922100.778694153, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 37366356.46134949, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2024 + }, + { + "base_gbp": -1129913657.7188382, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -661746627.0762091, + "key": "obr_pmd_nov2025:10976:230199453880fc45", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": 12695757.11100769, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 41882343.67687988, + "key": "obr_pmd_nov2025:22189:215059939a4b5efc", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -1117217900.6078305, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -619864283.3993292, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -1129913657.7188382, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -661746627.0762091, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": 12695757.11100769, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 41882343.67687988, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2025 + }, + { + "base_gbp": -1167913753.6299438, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -696073741.2305636, + "key": "obr_pmd_nov2025:10977:57eced9fbf6bab3b", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": 15403093.633636475, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 44096154.611190796, + "key": "obr_pmd_nov2025:22190:dc5ace295177c84b", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -1152510659.9963074, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -651977586.6193728, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -1167913753.6299438, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -696073741.2305636, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": 15403093.633636475, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 44096154.611190796, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2026 + }, + { + "base_gbp": -1215475927.791809, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -738860279.7822428, + "key": "obr_pmd_nov2025:10978:58862ededbda2b44", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": 16239100.814880371, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 47827344.751449585, + "key": "obr_pmd_nov2025:22191:6f3ee686a895fd60", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -1199236826.9769287, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -691032935.0307932, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -1215475927.791809, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -738860279.7822428, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": 16239100.814880371, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 47827344.751449585, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2027 + }, + { + "base_gbp": -1263944357.8132381, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -782252598.0831976, + "key": "obr_pmd_nov2025:10979:3e84f55a7dc5bd63", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 16707183.741485596, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 49616282.76098633, + "key": "obr_pmd_nov2025:22192:79bd1761809b7468", + "level": "source_row", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": -1247237174.0717525, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -732636315.3222113, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": -1263944357.8132381, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -782252598.0831976, + "key": "ni_class_4", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 16707183.741485596, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": 49616282.76098633, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_budget_2024__class_4_self_employed_nics_main_rate_cut_2pp", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:10986:8084652420f5c808", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:22205:3c227ad5b7739a89", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2023 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "support" + ], + "head_gbp": null, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2023 + }, + { + "base_gbp": -1491806310.2611084, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -779631545.703949, + "key": "obr_pmd_nov2025:10987:0f254c98c34bc660", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2024 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -336165102.39004517, + "key": "obr_pmd_nov2025:22206:49a0caec037600d2", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2024 + }, + { + "base_gbp": -1491806310.2611084, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1115796648.0939941, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2024 + }, + { + "base_gbp": -0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -336165102.39004517, + "key": "child_benefit", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2024 + }, + { + "base_gbp": -1491806310.2611084, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -779631545.703949, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2024 + }, + { + "base_gbp": -1592760827.531311, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -980457483.7055664, + "key": "obr_pmd_nov2025:10988:6a33f7863750611c", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2025 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -282526333.77780914, + "key": "obr_pmd_nov2025:22207:01b31afdbe23dae6", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2025 + }, + { + "base_gbp": -1592760827.531311, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1262983817.4833755, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2025 + }, + { + "base_gbp": -0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -282526333.77780914, + "key": "child_benefit", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2025 + }, + { + "base_gbp": -1592760827.531311, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -980457483.7055664, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2025 + }, + { + "base_gbp": -1720629444.0709229, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1131591240.399292, + "key": "obr_pmd_nov2025:10989:43638ad22300c9c3", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2026 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -307810876.10124016, + "key": "obr_pmd_nov2025:22208:6c19f800730945b5", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2026 + }, + { + "base_gbp": -1720629444.0709229, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1439402116.5005322, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2026 + }, + { + "base_gbp": -0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -307810876.10124016, + "key": "child_benefit", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2026 + }, + { + "base_gbp": -1720629444.0709229, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1131591240.399292, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2026 + }, + { + "base_gbp": -1844209824.508789, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1274651222.2009888, + "key": "obr_pmd_nov2025:10990:e86af1dbeb4da64e", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2027 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -323556121.7815895, + "key": "obr_pmd_nov2025:22209:71f606ed0f936afe", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2027 + }, + { + "base_gbp": -1844209824.508789, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1598207343.9825783, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2027 + }, + { + "base_gbp": -0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -323556121.7815895, + "key": "child_benefit", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2027 + }, + { + "base_gbp": -1844209824.508789, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1274651222.2009888, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2027 + }, + { + "base_gbp": -1972030891.008606, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1394705720.206482, + "key": "obr_pmd_nov2025:10991:11a476b01436c0db", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2028 + }, + { + "base_gbp": 0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -363073039.4392204, + "key": "obr_pmd_nov2025:22210:0ea61ade0bedb0f5", + "level": "source_row", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2028 + }, + { + "base_gbp": -1972030891.008606, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1757778759.6457024, + "key": "total", + "level": "measure_year", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2028 + }, + { + "base_gbp": -0.0, + "event": "spring_budget_2024", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -363073039.4392204, + "key": "child_benefit", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2028 + }, + { + "base_gbp": -1972030891.008606, + "event": "spring_budget_2024", + "flags": [ + "numerical" + ], + "head_gbp": -1394705720.206482, + "key": "income_tax", + "level": "head", + "measure_key": "spring_budget_2024__hicbc_threshold_and_taper", + "year": 2028 + }, + { + "base_gbp": 264112675.57543182, + "event": "spring_statement_2025", + "flags": [ + "inertness" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:23317:1b983354102bb14d", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2026 + }, + { + "base_gbp": 264112675.57543182, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural", + "inertness" + ], + "head_gbp": 0.0, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2026 + }, + { + "base_gbp": 264112675.57543182, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -0.0, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2026 + }, + { + "base_gbp": 338556341.93270874, + "event": "spring_statement_2025", + "flags": [ + "inertness" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:23318:dcc529f309fec572", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2027 + }, + { + "base_gbp": 338556341.93270874, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural", + "inertness" + ], + "head_gbp": 0.0, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2027 + }, + { + "base_gbp": 338556341.93270874, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -0.0, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2027 + }, + { + "base_gbp": 436340777.1042633, + "event": "spring_statement_2025", + "flags": [ + "inertness" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:23319:c6ce8500db20415e", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2028 + }, + { + "base_gbp": 436340777.1042633, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural", + "inertness" + ], + "head_gbp": 0.0, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2028 + }, + { + "base_gbp": 436340777.1042633, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -0.0, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2028 + }, + { + "base_gbp": 626601979.8689651, + "event": "spring_statement_2025", + "flags": [ + "inertness" + ], + "head_gbp": null, + "key": "obr_pmd_nov2025:23320:4c41d84635ed82ad", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2029 + }, + { + "base_gbp": 626601979.8689651, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural", + "inertness" + ], + "head_gbp": 0.0, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2029 + }, + { + "base_gbp": 626601979.8689651, + "event": "spring_statement_2025", + "flags": [ + "numerical", + "structural" + ], + "head_gbp": -0.0, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut", + "year": 2029 + }, + { + "base_gbp": -403656800.85860443, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -723462767.5098114, + "key": "obr_pmd_nov2025:23335:7e9f9d66b5194ff6", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2026 + }, + { + "base_gbp": -403656800.85860443, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -723462767.5098114, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2026 + }, + { + "base_gbp": -403656800.85860443, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -723462767.5098114, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2026 + }, + { + "base_gbp": -409490512.0053253, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -732669693.5323181, + "key": "obr_pmd_nov2025:23336:5d8fea1a1f3c86d6", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2027 + }, + { + "base_gbp": -409490512.0053253, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -732669693.5323181, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2027 + }, + { + "base_gbp": -409490512.0053253, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -732669693.5323181, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2027 + }, + { + "base_gbp": -414257872.6100464, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -746613549.421051, + "key": "obr_pmd_nov2025:23337:31f0f1693e3bba3c", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2028 + }, + { + "base_gbp": -414257872.6100464, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -746613549.421051, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2028 + }, + { + "base_gbp": -414257872.6100464, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -746613549.421051, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2028 + }, + { + "base_gbp": -421859102.8292618, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -753234184.9562225, + "key": "obr_pmd_nov2025:23338:27e34c93a163b4f0", + "level": "source_row", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2029 + }, + { + "base_gbp": -421859102.8292618, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -753234184.9562225, + "key": "total", + "level": "measure_year", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2029 + }, + { + "base_gbp": -421859102.8292618, + "event": "spring_statement_2025", + "flags": [ + "numerical" + ], + "head_gbp": -753234184.9562225, + "key": "universal_credit", + "level": "head", + "measure_key": "spring_statement_2025__uc_standard_allowance_above_inflation", + "year": 2029 + } + ] + }, + "execution": { + "dataset_sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784", + "packaged_bundle_manifest_sha256": "fc1abd76660e9b0bdfe6319a2ae5f221847750e087143e5ba6da859a9422e333", + "packages": { + "policyengine": { + "record_sha256": "292af3479d12a7ad10cc8c84377e81cf5362d75b8289603c0948968f53a24790", + "version": "6.4.1", + "wheel_sha256": "550c3a9ae9c74d409d2fa81b7090850c2e5115b57f91ef1814f3c5fddaca36f2" + }, + "policyengine-core": { + "record_sha256": "53a92f2e67982a16faeba61b8b3d145d3ab9a50d298e92c512d61166ffe5d5d4", + "version": "3.33.0", + "wheel_sha256": "2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789" + }, + "policyengine-uk": { + "record_sha256": "365cc5256f85b474bd445403462e17e468a1c2f5da6cc2c2b1cbf626ac20e243", + "version": "2.130.1", + "wheel_sha256": "f8b1b8b917c9297ff66937811dbebd9a62d3b1796ce894c326e98051cac4c873" + } + }, + "platform": "Linux-6.17.0-1022-azure-x86_64-with-glibc2.39", + "python": "3.12.3", + "release_bundle": { + "bundle_id": "uk-6.4.1", + "certified_by": "policyengine.py bundle certification", + "certified_data_artifact_sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784", + "certified_data_build_id": "policyengine-uk-data-1.58.1", + "compatibility_basis": "legacy_compatible_model_package", + "country_id": "uk", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "data_build_model_git_sha": "daad71cd26c107f1b784ff600d1464ba59c70e4a", + "data_build_model_version": "2.122.2", + "data_package": "policyengine-uk-data", + "data_version": "1.58.1", + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.1", + "model_package": "policyengine-uk", + "model_version": "2.130.1", + "policyengine_version": "6.4.1" + }, + "requirements_sha256": "d1636b4d91af2a1e1802f5e7551a7bf3023ac1124db7730d31d05925725024d9", + "runner": "github-actions Linux X64" + }, + "integrity": { + "failures": [], + "status": "incomplete" + }, + "receipt_sha256": "e3cc6521f0cf2273fc1e7970983421d14a668afc5d21f829a71775b15873f34f", + "rejection": null, + "request_id": "d3d242e761b9b5177ae77a2528e1cd3dab306cb73350e79648551dc99c288412", + "results": { + "manifest_sha256": "9def79c1b2903ddabdc8bf70e618d478a3c2950deb49f6ec31ff5e3025718056", + "path": "requests/d3d242e761b9b5177ae77a2528e1cd3dab306cb73350e79648551dc99c288412/results/", + "ref": "replay-state", + "repository": "PolicyEngine/policyengine-scorecard" + }, + "schema": "policyengine.uk-replay-receipt/v1", + "status": "complete", + "variant_id": "pe6.4.1_uk2.130.1_policyengine-uk-data-1.58.1", + "variant_identity_sha256": "fb1d19d6469a82dd447fdba3aff65daab22da9ed654a718a572da8d0da40d774" +} diff --git a/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REQUEST.json b/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REQUEST.json new file mode 100644 index 000000000..35e82fcd3 --- /dev/null +++ b/tests/fixtures/uk_replay/receipt_6.4.1_attempt1/REQUEST.json @@ -0,0 +1,59 @@ +{ + "attempt": 1, + "candidate": { + "data": { + "build_id": "policyengine-uk-data-1.58.1", + "dataset": "enhanced_frs_2024_25", + "path": "enhanced_frs_2024_25.h5", + "release_manifest_sha256": "545797bf231cd3681e64b677065d921304e9a32f1e46e757a940a412b4136c17", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.1", + "sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784", + "size_bytes": 128409762 + }, + "expected_release_bundle": { + "bundle_id": "uk-6.4.1", + "certified_by": "policyengine.py bundle certification", + "certified_data_artifact_sha256": "056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784", + "certified_data_build_id": "policyengine-uk-data-1.58.1", + "compatibility_basis": "legacy_compatible_model_package", + "country_id": "uk", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "data_build_model_git_sha": "daad71cd26c107f1b784ff600d1464ba59c70e4a", + "data_build_model_version": "2.122.2", + "data_package": "policyengine-uk-data", + "data_version": "1.58.1", + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.1", + "model_package": "policyengine-uk", + "model_version": "2.130.1", + "policyengine_version": "6.4.1" + }, + "policyengine": { + "packaged_bundle_manifest_sha256": "fc1abd76660e9b0bdfe6319a2ae5f221847750e087143e5ba6da859a9422e333", + "source": { + "pypi": { + "wheel_sha256": "550c3a9ae9c74d409d2fa81b7090850c2e5115b57f91ef1814f3c5fddaca36f2" + } + }, + "version": "6.4.1" + }, + "policyengine_core": { + "version": "3.33.0", + "wheel_sha256": "2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789" + }, + "policyengine_uk": { + "version": "2.130.1", + "wheel_sha256": "f8b1b8b917c9297ff66937811dbebd9a62d3b1796ce894c326e98051cac4c873" + } + }, + "compare_to": "latest_certified", + "kind": "released_bundle", + "requested_by": { + "commit": "0ccad05d2eecb99acbfa125599a419b51d07f0e1", + "note": "Built by hand by the W4 session on 2026-10-15 for policyengine 6.4.1 (release commit 0ccad05d), with released_bundle_request from policyengine.py PR #574 at 769d5efa, because the push workflow's NotifyConsumers request is not on main yet. No workflow run produced it.", + "repository": "PolicyEngine/policyengine.py" + }, + "schema": "policyengine.uk-replay-qualification-request/v1" +} diff --git a/tests/test_qualification_contracts.py b/tests/test_qualification_contracts.py index 642d67477..3c781379e 100644 --- a/tests/test_qualification_contracts.py +++ b/tests/test_qualification_contracts.py @@ -12,7 +12,10 @@ - the replay request, status and receipt mirrors agree with the scorecard's vendored JSON Schemas and reproduce its golden digests (differential); - a replay receipt counts only when bound to this request and these - artifacts; blocking flags fail, review flags need review. + artifacts; +- the replay policy (d1344-replay-regressions-v1) fails exactly the + regressions it names, sends every other flagged row to review, and is + monotone: adding a flagged row never makes the verdict better. """ from __future__ import annotations @@ -28,6 +31,7 @@ from policyengine.provenance.canonical import canonical_sha256 from policyengine.provenance.qualification import ( + REPLAY_POLICY_ID, REQUIRED_GATES, GateResult, ReplayQualificationRequest, @@ -471,11 +475,19 @@ def _with_counts(sealed=True, **counts): return _sealed(doc) if sealed else doc -def test_golden_receipt_fails_on_its_structural_flag(): +def test_golden_receipt_needs_review_for_its_sign_reversal(): + """Its structural row moves £0.0000001 to -£123m: a sign reversal, which a + model fix can make legitimately, so a named reviewer decides.""" + doc = _with_requested_wheels(copy.deepcopy(golden("replay_receipt_golden"))) gate = _gate(_sealed(doc)) - assert gate.status == "fail" - assert "structural" in gate.summary + assert gate.status == "needs_review", gate.summary + assert gate.details["regressions"] == [] + assert sorted(item["level"] for item in gate.details["review_items"]) == [ + "head", + "measure_year", + "source_row", + ] @pytest.mark.parametrize( @@ -484,9 +496,12 @@ def test_golden_receipt_fails_on_its_structural_flag(): ({}, "pass"), ({"numerical": 2}, "needs_review"), ({"support": 1}, "needs_review"), - ({"numerical": 2, "coverage": 1}, "fail"), - ({"construction": 1}, "fail"), - ({"inertness": 1}, "fail"), + # The template row has values on both sides: not a lost result. + ({"numerical": 2, "coverage": 1}, "needs_review"), + ({"construction": 1}, "needs_review"), + # Not in the candidate's inert list: inert in the base only. + ({"inertness": 1}, "needs_review"), + ({"structural": 1}, "needs_review"), ({"mapping": 3}, "fail"), ], ) @@ -548,7 +563,10 @@ def test_blocking_flags_count_wherever_the_receipt_states_them(): assert "mapping" in _gate(_sealed(receipt)).summary -def test_an_inert_measure_year_without_a_flag_needs_review(): +def test_a_measure_year_the_candidate_lists_as_inert_fails(): + """No receipt yet records the base's inert measure-years, so no gap is + known, and an inert measure-year is a lost response.""" + receipt = _edit( _clean_receipt(), lambda r: r["corpus"]["events"][EVENT].update( @@ -556,7 +574,389 @@ def test_an_inert_measure_year_without_a_flag_needs_review(): ), ) gate = _gate(receipt) - assert gate.status == "needs_review" and "inert" in gate.summary + assert gate.status == "fail" and "lost_response 1" in gate.summary + assert gate.details["known_gaps"] == [] + + +MEASURE = "spring_budget_2023__uc_childcare_cap_increase" + + +def _receipt_with(rows=(), inert=(), complete=True, integrity="complete", counts=None): + """The golden receipt with these flagged rows (measure-year totals unless + a row says otherwise), counts that match them, and this event state.""" + + doc = _with_requested_wheels(copy.deepcopy(golden("replay_receipt_golden"))) + template = { + "event": EVENT, + "measure_key": MEASURE, + "year": 2026, + "level": "measure_year", + "key": "total", + } + doc["diff"]["flagged_rows"] = [{**template, **row} for row in rows] + tally = {key: 0 for key in doc["diff"]["flag_counts"]} + for row in doc["diff"]["flagged_rows"]: + for kind in row["flags"]: + tally[kind] += 1 + tally.update(counts or {}) + doc["diff"]["flag_counts"] = tally + event = doc["corpus"]["events"][EVENT] + event["inert_measure_years"] = [ + {"measure_key": measure, "year": year} for measure, year in inert + ] + event["full_supported_grid_complete"] = complete + doc["integrity"]["status"] = integrity + return _sealed(doc) + + +# The regression table of the d1344 decision record (second review), plus the +# bound's edges. Each: rows, candidate inert, grid complete, integrity, then +# the expected status and the kinds the gate must record. +POLICY_CASES = { + "sign reversal": ( + [{"flags": ["numerical", "structural"], "base_gbp": 1e8, "head_gbp": -1e8}], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), + "£0 against a material base, distinct worlds": ( + [{"flags": ["numerical", "structural"], "base_gbp": 1e8, "head_gbp": 0.0}], + (), + True, + "complete", + "fail", + {"lost_response"}, + ), + "exactly the bound to £0, no numerical flag": ( + [{"flags": ["structural"], "base_gbp": 1e6, "head_gbp": 0.0}], + (), + True, + "complete", + "fail", + {"lost_response"}, + ), + "negative material base to £0": ( + [{"flags": ["structural"], "base_gbp": -2.5e8, "head_gbp": 0.0}], + (), + True, + "complete", + "fail", + {"lost_response"}, + ), + "just below the bound to £0": ( + [{"flags": ["structural"], "base_gbp": 999_999.0, "head_gbp": 0.0}], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), + "£0 head (not a measure-year total)": ( + [ + { + "flags": ["structural"], + "level": "head", + "key": "universal_credit", + "base_gbp": 1e8, + "head_gbp": 0.0, + } + ], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), + "numeric base result, missing candidate result": ( + [ + { + "flags": ["coverage"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": 5e8, + "head_gbp": None, + } + ], + (), + True, + "complete", + "fail", + {"lost_result"}, + ), + "coverage gain": ( + [ + { + "flags": ["coverage"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": None, + "head_gbp": 574_999_000.021347, + } + ], + (), + True, + "complete", + "needs_review", + {"coverage_gain"}, + ), + "null/null coverage, otherwise complete": ( + [ + { + "flags": ["coverage"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": None, + "head_gbp": None, + } + ], + (), + True, + "complete", + "needs_review", + {"coverage_without_values"}, + ), + "absent base, candidate inert and incomplete": ( + [ + { + "flags": ["coverage"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": None, + "head_gbp": None, + } + ], + ((MEASURE, 2026),), + False, + "incomplete", + "fail", + {"lost_response", "incomplete", "coverage_without_values"}, + ), + # As in the 6.4.1 receipt: a measure-year flag and a source-row flag + # whose candidate value is null, for a measure-year the candidate lists. + "candidate inert, flagged against the base": ( + [ + {"flags": ["inertness"], "base_gbp": 2.641127e8, "head_gbp": 0.0}, + { + "flags": ["inertness"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": 2.641127e8, + "head_gbp": None, + }, + ], + ((MEASURE, 2026),), + False, + "incomplete", + "fail", + {"lost_response", "incomplete"}, + ), + "inert in the base only": ( + [{"flags": ["inertness"], "base_gbp": 0.0, "head_gbp": 3e8}], + (), + True, + "complete", + "needs_review", + {"base_only_inertness"}, + ), + "same supported artifact missing on both sides": ( + [], + (), + False, + "incomplete", + "fail", + {"incomplete"}, + ), + "incomplete integrity with no incomplete event": ( + [], + (), + True, + "incomplete", + "fail", + {"incomplete"}, + ), + "candidate loses a supported year": ( + [ + { + "flags": ["support"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": 1e8, + "head_gbp": None, + } + ], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), + "construction": ( + [{"flags": ["construction"], "base_gbp": 1e8, "head_gbp": 1.2e8}], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), + "mapping": ( + [{"flags": ["mapping"], "base_gbp": 1e8, "head_gbp": 1e8}], + (), + True, + "complete", + "fail", + {"mapping"}, + ), +} + + +@pytest.mark.parametrize("name", sorted(POLICY_CASES)) +def test_the_replay_policy_decides_each_case_of_the_decision_record(name): + rows, inert, complete, integrity, status, kinds = POLICY_CASES[name] + receipt = _receipt_with(rows, inert, complete, integrity) + _validator("replay_receipt.v1.json").validate(receipt) + gate = _gate(receipt) + assert gate.status == status, (name, gate.summary) + recorded = { + item["kind"] + for item in gate.details["regressions"] + gate.details["review_items"] + } + assert recorded == kinds, (name, recorded) + + +def test_a_counted_flag_with_no_row_still_needs_review(): + gate = _gate(_receipt_with(counts={"numerical": 1})) + assert gate.status == "needs_review" + assert gate.details["review_items"] == [ + {"kind": "counted_flag", "flag": "numerical", "count": 1} + ] + + +def test_the_gate_records_the_policy_it_applied(): + import hashlib + from importlib.resources import files + + gate = _gate(_receipt_with()) + assert gate.status == "pass" + text = (files("policyengine.data.qualification") / "replay_policy.md").read_bytes() + assert gate.details["policy"] == REPLAY_POLICY_ID + assert gate.details["policy_sha256"] == hashlib.sha256(text).hexdigest() + assert REPLAY_POLICY_ID.encode() in text + assert gate.details["known_gaps"] == [] + + +def test_the_first_real_receipt_fails_on_its_inert_uc_measure_years(): + """policyengine 6.4.1 + policyengine-uk 2.130.1 + uk-data 1.58.1, receipt + e3cc6521…: scorecard's construction of spring_statement_2025's UC health + element change was inert in 2026-29, where the base computed £264.1m to + £626.6m. Those four lost responses and the incomplete grid fail it; its + other 346 flagged rows are review items.""" + + from collections import Counter + + folder = FIXTURES / "receipt_6.4.1_attempt1" + request = ReplayQualificationRequest.model_validate( + json.loads((folder / "REQUEST.json").read_text()) + ) + document = json.loads((folder / "REPLAY_RECEIPT.json").read_text()) + receipt = ReplayReceipt.from_document(document) + assert receipt.computed_receipt_sha256 == receipt.receipt_sha256 + gate = evaluate_replay_receipt( + request, _status(request.request_id, document), receipt + ) + assert gate.status == "fail" + lost = sorted( + (item["measure_key"], item["year"]) + for item in gate.details["regressions"] + if item["kind"] == "lost_response" + ) + measure = "spring_statement_2025__uc_health_element_freeze_and_new_claimant_cut" + assert lost == [(measure, year) for year in (2026, 2027, 2028, 2029)] + assert [ + item["event"] + for item in gate.details["regressions"] + if item["kind"] == "incomplete" + ] == ["spring_statement_2025"] + assert Counter(item["kind"] for item in gate.details["review_items"]) == { + "flagged_move": 345, + "coverage_gain": 1, + } + + +_ROW = st.fixed_dictionaries( + { + "flags": st.lists( + st.sampled_from( + [ + "numerical", + "structural", + "coverage", + "mapping", + "support", + "inertness", + "construction", + ] + ), + min_size=1, + max_size=3, + unique=True, + ), + "level": st.sampled_from(["source_row", "measure_year", "head"]), + "year": st.sampled_from([2025, 2026]), + "base_gbp": st.one_of( + st.none(), + st.just(0.0), + st.sampled_from([999_999.0, 1e6, -1e6, 2.5e8, -2.5e8]), + st.floats(-1e10, 1e10, allow_nan=False), + ), + "head_gbp": st.one_of( + st.none(), + st.just(0.0), + st.floats(-1e10, 1e10, allow_nan=False), + ), + } +) +_RANK = {"pass": 0, "needs_review": 1, "fail": 2} + + +@settings(max_examples=150, deadline=None) +@given( + rows=st.lists(_ROW, max_size=4), + extra=_ROW, + inert=st.lists(st.sampled_from([2025, 2026]), max_size=2, unique=True), +) +def test_the_replay_policy_is_monotone_and_never_passes_a_flag(rows, extra, inert): + """For every receipt: a flagged row never passes; mapping and lost results + always fail; fail is exactly "a regression was recorded"; and adding a + flagged row never makes the verdict better.""" + + listed = tuple((MEASURE, year) for year in inert) + complete = not listed + + def judge(these): + receipt = _receipt_with( + these, listed, complete, "complete" if complete else "incomplete" + ) + return _gate(receipt) + + before = judge(rows) + after = judge([*rows, extra]) + assert _RANK[after.status] >= _RANK[before.status] + for gate, these in ((before, rows), (after, [*rows, extra])): + assert (gate.status == "fail") == bool(gate.details["regressions"]) + if these or listed: + assert gate.status != "pass" + if any( + "mapping" in row["flags"] + or ( + "coverage" in row["flags"] + and row["base_gbp"] is not None + and row["head_gbp"] is None + ) + for row in these + ): + assert gate.status == "fail" def test_the_comparison_base_must_be_the_one_requested(): From 0699ddb7a387b629e7c890746aeed4f222678b11 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 19:18:47 -0400 Subject: [PATCH 02/18] Name the changelog fragment after its issue (#634) Co-Authored-By: Claude Opus 5.5 --- .../{w4-d1344-automatic-promotion.changed.md => 634.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{w4-d1344-automatic-promotion.changed.md => 634.changed.md} (100%) diff --git a/changelog.d/w4-d1344-automatic-promotion.changed.md b/changelog.d/634.changed.md similarity index 100% rename from changelog.d/w4-d1344-automatic-promotion.changed.md rename to changelog.d/634.changed.md From 3b93070745764803b23aca1c21fa8b5a26774c77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 23:01:42 -0400 Subject: [PATCH 03/18] Requalify a promotable target whose baseline went stale With automatic promotion, a target qualified against an earlier certificate than main's must not wait for a manual --force. A promotable pair (qualified, or needs-review with an exact acceptance) that advances main in every component, but whose previous certificate differs from main's, now resolves to "qualify" without force, once per main commit: an attempt from the current base makes later runs duplicates. The promotion plan names that pair (PromotionPlan.requalify), a new `requalify` command prints it, and the two-hourly catch-up dispatches the qualification after it has finished and published waiting receipts. Rejected, unaccepted and sideways pairs are never requalified. Five mutants (promotability, the advance check, the baseline check, the per-commit dedupe and the plan field) are each killed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 27 +++++ scripts/check_uk_candidate.py | 18 ++++ .../provenance/uk_qualification.py | 29 +++++- tests/test_uk_qualification_pipeline.py | 99 ++++++++++++++++++- tests/test_uk_qualification_workflow.py | 23 +++++ 5 files changed, 193 insertions(+), 3 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index d9bf360b9..bbf30092e 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -427,6 +427,33 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python scripts/check_uk_candidate.py refresh-pr-checks --ledger ledger + # Automatic promotion needs the target qualified against main's current + # certificate. A run without force requalifies it once per main commit; + # repeated catch-ups on the same commit are deduplicated as duplicates. + - name: Requalify a promotable target qualified against an earlier certificate + env: + GH_TOKEN: ${{ github.token }} + run: | + python scripts/check_uk_candidate.py requalify --ledger ledger > requalify.jsonl + python - <<'PY' + import json + import os + import subprocess + from pathlib import Path + + for line in Path("requalify.jsonl").read_text().splitlines(): + pair = json.loads(line)["pair"] + subprocess.run( + [ + "gh", "workflow", "run", "certify-uk-release.yaml", + "--repo", os.environ["GITHUB_REPOSITORY"], "--ref", "main", + "-f", f"model_version={pair['model']['version']}", + "-f", f"data_version={pair['data']['release_version']}", + "-f", "force=false", + ], + check=True, + ) + PY accept: if: github.event_name == 'workflow_dispatch' && inputs.accept_pair_id != '' diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index e88246812..0c37fae7d 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -493,6 +493,20 @@ def cmd_recoverable(args: argparse.Namespace) -> int: return 0 +def cmd_requalify(args: argparse.Namespace) -> int: + """Print the promotion target's pair when its baseline is stale.""" + + bundle = json.loads(BUNDLE_PATH.read_text()) + problems: list[str] = [] + receipts = latest_receipts(Path(args.ledger), problems=problems) + for problem in problems: + print(f"::warning::{problem}", file=sys.stderr) + plan = plan_promotion(receipts, bundle, {}) + if plan.requalify is not None: + print(json.dumps({"pair": plan.requalify.canonical_payload()})) + return 0 + + def _open_candidate_branches() -> dict[str, str]: """Open candidate branches and the commit each one's pull request is at.""" @@ -709,6 +723,10 @@ def parser() -> argparse.ArgumentParser: recoverable.add_argument("--ledger", required=True) recoverable.set_defaults(func=cmd_recoverable) + requalify = commands.add_parser("requalify") + requalify.add_argument("--ledger", required=True) + requalify.set_defaults(func=cmd_requalify) + promote = commands.add_parser("promote") promote.add_argument("--ledger", required=True) promote.add_argument("--apply", action="store_true") diff --git a/src/policyengine/provenance/uk_qualification.py b/src/policyengine/provenance/uk_qualification.py index 6d711cfc8..aa5fc948f 100644 --- a/src/policyengine/provenance/uk_qualification.py +++ b/src/policyengine/provenance/uk_qualification.py @@ -371,7 +371,12 @@ def candidate_action( now: Optional[str] = None, owner_is_failed: Optional[Callable[[str], bool]] = None, ) -> tuple[Action, str]: - """Whether to qualify ``pair`` now, and why.""" + """Whether to qualify ``pair`` now, and why. + + A promotable pair that advances main but was qualified against an + earlier certificate is requalified once per main commit, so automatic + promotion never waits on a manual ``--force``. + """ current = certified_order_key(bundle) record = bundle["data_releases"]["uk"] @@ -402,6 +407,22 @@ def candidate_action( ) ): return "qualify", "recoverable abandoned staged attempt" + if ( + receipt is not None + and receipt.promotable + and dominates(pair.order_key, current) + and receipt.previous_certificate != previous_certificate(bundle) + ): + # Promotion needs a qualification against main's current + # certificate. Requalify once per main commit, without force. + if attempt is not None: + return "duplicate", ( + f"a requalification from base {base_commit[:12]} exists" + ) + return "qualify", ( + "promotable against a superseded baseline; requalifying against " + "current main" + ) if receipt is not None and receipt.verdict != "incomplete": return "duplicate", f"pair already {receipt.verdict} in the ledger" if attempt is not None: @@ -1207,6 +1228,9 @@ class PromotionPlan: reason: str = "" #: The qualified commit the ready branch must still be at when promoted. ready_commit: Optional[str] = None + #: The target, when its qualification compared against an earlier + #: certificate than main's: it needs requalifying before promotion. + requalify: Optional[CandidatePair] = None def plan_promotion( @@ -1258,9 +1282,10 @@ def plan_promotion( close_branches=sorted(close), reason=( f"{target.pair.order_key} advances {current}, but its previous " - "certificate differs from current main; requalify with --force " + "certificate differs from current main; it is requalified " "against current main before promotion" ), + requalify=target.pair, ) branch = candidate_branch(target.pair) qualified_commit = target.harness.git_commit diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index f210e1032..dcd62c2d2 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -201,6 +201,101 @@ def test_actions(tmp_path): ) +def test_a_promotable_pair_on_a_superseded_baseline_requalifies_once_per_commit( + tmp_path, +): + """Automatic promotion needs a qualification against main's current + certificate, so a promotable pair that advances main but compared + against an earlier one is requalified without force, once per main + commit. Nothing else is.""" + + pair = _promotable_pair_and_branch() + current = previous_certificate(BUNDLE) + earlier = current.model_copy(update={"bundle_version": "earlier"}) + pair_dir = tmp_path / "uk" / pair.pair_id + + def publish(receipt, certificate=earlier): + receipt = receipt.model_copy(update={"previous_certificate": certificate}) + write_json(pair_dir / "qualification_receipt.json", receipt.canonical_payload()) + + def action(base): + return candidate_action(pair, BUNDLE, tmp_path, base_commit=base * 40)[0] + + qualified = make_receipt(pair=pair) + publish(qualified) + verdict, reason = candidate_action(pair, BUNDLE, tmp_path, base_commit="b" * 40) + assert verdict == "qualify" and "superseded baseline" in reason + (pair_dir / "attempts" / ("b" * 12)).mkdir(parents=True) + assert action("b") == "duplicate" + assert action("c") == "qualify" + needs_review = make_receipt( + pair=pair, gate_results=gates(metrics_review="needs_review") + ).model_copy(update={"previous_certificate": earlier}) + accepted = accept_review( + needs_review, + reviewer="reviewer", + accepted_receipt_sha256=needs_review.unreviewed_sha256, + explanation="expected", + accepted_at="2026-10-16T09:00:00Z", + ) + assert accepted.promotable + publish(accepted) + assert action("c") == "qualify" + # Against main's own certificate, or not promotable: nothing to do. + publish(qualified, current) + assert action("c") == "duplicate" + for results in (gates(metrics_review="needs_review"), gates(wheel_hashes="fail")): + publish(make_receipt(pair=pair, gate_results=results)) + assert action("c") == "duplicate" + # A pair that does not advance main in every component is never promoted. + sideways = make_pair( + model="2.200.0", + data="1.50.0", + core=BUNDLE["packages"]["policyengine-core"]["version"], + ) + write_json( + tmp_path / "uk" / sideways.pair_id / "qualification_receipt.json", + make_receipt(pair=sideways) + .model_copy(update={"previous_certificate": earlier}) + .canonical_payload(), + ) + assert ( + candidate_action(sideways, BUNDLE, tmp_path, base_commit="c" * 40)[0] + == "duplicate" + ) + + +def test_requalify_names_the_target_only_while_its_baseline_is_stale( + monkeypatch, tmp_path, capsys +): + import argparse + import importlib.util + + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_requalify", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + current = _promotion_receipt(pair=_promotable_pair_and_branch()) + stale = current.model_copy( + update={ + "previous_certificate": current.previous_certificate.model_copy( + update={"bundle_version": "earlier"} + ) + } + ) + for receipts, expected in (([stale], [stale.pair]), ([current], []), ([], [])): + monkeypatch.setattr(cli, "latest_receipts", lambda root, r=receipts, **_: r) + assert cli.cmd_requalify(argparse.Namespace(ledger=str(tmp_path))) == 0 + printed = [ + json.loads(line)["pair"] + for line in capsys.readouterr().out.splitlines() + if line + ] + assert printed == [pair.canonical_payload() for pair in expected] + + def test_incomplete_pairs_requalify_on_a_new_base_only(tmp_path): pair = make_pair(model="2.200.0") incomplete = make_receipt( @@ -756,7 +851,7 @@ def gate(pair, years, previous_years): plan = plan_promotion([receipt_b, receipt_c], current_b, branches) assert plan.target == receipt_c assert plan.ready_branch is None and plan.ready_commit is None - assert "requalif" in plan.reason and "--force" in plan.reason + assert "requalif" in plan.reason and plan.requalify == receipt_c.pair @pytest.mark.parametrize( @@ -812,6 +907,7 @@ def test_stale_baseline_does_not_close_or_fall_back_to_other_candidates(missing) assert plan.target == newest assert plan.ready_branch is None and plan.ready_commit is None assert plan.close_branches == [candidate_branch(superseded_by_main.pair)] + assert plan.requalify == newest.pair def test_promotion_on_an_unrelated_main_commit_keeps_the_same_baseline(): @@ -825,6 +921,7 @@ def test_promotion_on_an_unrelated_main_commit_keeps_the_same_baseline(): ) assert plan.ready_branch == branch assert plan.ready_commit == qualified.harness.git_commit + assert plan.requalify is None def test_a_forced_rerun_stages_a_new_attempt(tmp_path): diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 88b57b4c2..bdb85ac5c 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -95,6 +95,29 @@ def test_every_uv_step_runs_the_environment_its_job_installed(): ] +def test_catch_up_requalifies_a_stale_target_after_finishing_receipts(): + """The requalification dispatch runs without force, after waiting + receipts are finished and published, so it reads the newest ledger.""" + + job = _workflow()["jobs"]["catch-up"] + runs = [step.get("run", "") for step in job["steps"]] + requalify = next( + index + for index, run in enumerate(runs) + if "check_uk_candidate.py requalify" in run + ) + published = next( + index + for index, run in enumerate(runs) + if "qualification-ledger.sh publish" in run + ) + assert published < requalify + assert '"-f", "force=false"' in runs[requalify] + assert "force=true" not in runs[requalify] + assert job["steps"][requalify]["env"]["GH_TOKEN"] == "${{ github.token }}" + assert job["permissions"]["actions"] == "write" + + def test_preparation_syncs_the_candidate_lock_before_using_it(): """Snapshots, the bundle check and staging must run in the candidate's environment: synced from the lock preparation regenerates, after it is From 0b74ddb111dc8ab8ccde3ad218eb303be668bab6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 23:04:49 -0400 Subject: [PATCH 04/18] Turn on automatic UK promotion, merging only after checks pass cos d1344 (15 October 2026): automatic promotion is on once #574 lands on its gates; this PR merges after #574. promotion.automatic is now true, with that basis in uk.json. The automatic step used `gh pr merge --auto`, but main is not branch-protected and the repository has allow_auto_merge off, so it would have failed silently (check=False) and nothing would ever be promoted. Promotion now merges the readied candidate at its qualified commit only once every check on its pull request has passed (a skipped job counts as passed; pending, failed, cancelled, none reported, or an unreadable list waits for the next promote run). --match-head-commit still refuses a branch that moved. Four mutants of the check gate are killed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 4 +- scripts/check_uk_candidate.py | 77 ++++++++++++++++----- src/policyengine/data/qualification/uk.json | 4 +- tests/test_uk_qualification_pipeline.py | 75 ++++++++++++++++++-- 4 files changed, 134 insertions(+), 26 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index bbf30092e..02293de1d 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -7,8 +7,8 @@ # mandatory gates on that exact commit, replayed by policyengine-scorecard, # and recorded in the uk-qualification-ledger branch. Promotion is # serialized: it readies the newest promotable candidate PR that advances -# main and closes superseded ones. Merging stays a reviewed step until -# cos d1344 rules on automatic promotion. +# main, closes superseded ones, and (cos d1344) merges it at its qualified +# commit once every check on it has passed. # # Triggers: a policyengine-uk publish or uk-data release (repository # dispatch), a two-hourly catch-up, or a manual run. A manual run with diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 0c37fae7d..a87160769 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -622,26 +622,69 @@ def cmd_promote(args: argparse.Namespace) -> int: check=False, ) if config()["promotion"]["automatic"]: - # GitHub refuses the merge if the branch moved off the qualified - # commit between the plan and the merge. - subprocess.run( - [ - "gh", - "pr", - "merge", - plan.ready_branch, - "--repo", - LEDGER_REPOSITORY, - "--auto", - "--squash", - "--match-head-commit", - plan.ready_commit, - ], - check=False, - ) + # main is not branch-protected and the repository does not allow + # auto-merge, so merge only once every check on the qualified + # head has finished without failing; later runs retry. GitHub + # refuses the merge if the branch moved off the qualified commit. + waiting = _unfinished_checks(plan.ready_branch) + if waiting: + print( + f"automatic promotion waits for {plan.ready_branch}: " + + ", ".join(waiting) + ) + else: + subprocess.run( + [ + "gh", + "pr", + "merge", + plan.ready_branch, + "--repo", + LEDGER_REPOSITORY, + "--squash", + "--match-head-commit", + plan.ready_commit, + ], + check=False, + ) return 0 +def _unfinished_checks(branch: str) -> list[str]: + """Checks on ``branch``'s pull request that have not passed. + + A skipped job counts as passed; a pending, failed or cancelled one does + not, and neither does a pull request reporting no checks at all. + """ + + listing = subprocess.run( + [ + "gh", + "pr", + "checks", + branch, + "--repo", + LEDGER_REPOSITORY, + "--json", + "name,bucket", + ], + check=False, + capture_output=True, + text=True, + ) + try: + checks = json.loads(listing.stdout or "[]") + except json.JSONDecodeError: + return ["the check list could not be read"] + if not checks: + return ["no checks reported yet"] + return sorted( + f"{check.get('name')} ({check.get('bucket')})" + for check in checks + if check.get("bucket") not in ("pass", "skipping") + ) + + def cmd_accept(args: argparse.Namespace) -> int: pair_dir = pair_directory(Path(args.ledger), "uk", args.pair_id) path = pair_dir / "qualification_receipt.json" diff --git a/src/policyengine/data/qualification/uk.json b/src/policyengine/data/qualification/uk.json index 00281419b..eaa7527e4 100644 --- a/src/policyengine/data/qualification/uk.json +++ b/src/policyengine/data/qualification/uk.json @@ -34,7 +34,7 @@ "standard_reforms_file": "standard_reforms.yaml" }, "promotion": { - "automatic": false, - "decision": "cos d1344: automatic promotion and review bounds are methodology calls; until ruled, promotion is a reviewed PR merge." + "automatic": true, + "decision": "cos d1344 (15 October 2026): automatic promotion is on once policyengine.py#574 lands on its gates. The promote job merges the newest promotable candidate PR that advances main, at its qualified commit, once every check on it has passed. A needs-review receipt is promoted only after a named reviewer accepts that exact receipt." } } diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index dcd62c2d2..4454e8451 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -735,7 +735,33 @@ def test_a_branch_that_moved_off_the_qualified_commit_is_not_promoted(): assert plan.ready_branch == branch -def test_promote_readies_and_merges_only_the_qualified_commit(monkeypatch, tmp_path): +CHECKS_PASSED = [ + {"name": "Lint", "bucket": "pass"}, + {"name": "Test (3.13)", "bucket": "pass"}, + {"name": "Docs", "bucket": "skipping"}, +] + + +@pytest.mark.parametrize( + ("checks", "merges"), + [ + (CHECKS_PASSED, True), + ([*CHECKS_PASSED[:1], {"name": "Test (3.13)", "bucket": "pending"}], False), + ([*CHECKS_PASSED[:1], {"name": "Test (3.13)", "bucket": "fail"}], False), + ([{"name": "Test (3.13)", "bucket": "cancel"}], False), + ([], False), + (None, False), + ], + ids=["passed", "pending", "failed", "cancelled", "none", "unreadable"], +) +def test_promote_readies_and_merges_only_the_qualified_commit( + monkeypatch, tmp_path, checks, merges +): + """main is not branch-protected and the repository does not allow + auto-merge, so automatic promotion merges the qualified head only once + every check on it has passed (skipped jobs aside), and never by + --auto.""" + import argparse import importlib.util @@ -748,10 +774,17 @@ def test_promote_readies_and_merges_only_the_qualified_commit(monkeypatch, tmp_p cli = importlib.util.module_from_spec(spec) spec.loader.exec_module(cli) calls = [] + + def run(argv, **kwargs): + calls.append(argv) + if argv[:3] == ["gh", "pr", "checks"]: + assert argv[3] == branch + stdout = "not json" if checks is None else json.dumps(checks) + return SimpleNamespace(returncode=0, stdout=stdout) + return SimpleNamespace(returncode=0, stdout="") + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) - monkeypatch.setattr( - cli, "subprocess", SimpleNamespace(run=lambda argv, **k: calls.append(argv)) - ) + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: "f" * 40}) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 @@ -763,7 +796,39 @@ def test_promote_readies_and_merges_only_the_qualified_commit(monkeypatch, tmp_p ) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert calls[0][:3] == ["gh", "pr", "ready"] and calls[0][3] == branch - assert calls[1][-2:] == ["--match-head-commit", qualified.harness.git_commit] + merged = [argv for argv in calls if argv[:3] == ["gh", "pr", "merge"]] + if merges: + assert merged == [ + [ + "gh", + "pr", + "merge", + branch, + "--repo", + "PolicyEngine/policyengine.py", + "--squash", + "--match-head-commit", + qualified.harness.git_commit, + ] + ] + else: + assert merged == [] + # Without automatic promotion it only readies. + calls.clear() + monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": False}}) + assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 + assert [argv[:3] for argv in calls] == [["gh", "pr", "ready"]] + + +def test_automatic_promotion_is_on_with_its_basis_recorded(): + config = json.loads( + ( + Path(__file__).resolve().parents[1] + / "src/policyengine/data/qualification/uk.json" + ).read_text() + ) + assert config["promotion"]["automatic"] is True + assert "d1344" in config["promotion"]["decision"] def test_promotion_requires_the_current_supported_years_baseline(monkeypatch): From 7915d55fcd1b859d804b964419055782d1e8f5ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 23:47:15 -0400 Subject: [PATCH 05/18] Promote exactly the qualified tree, and answer #635's round-1 review High: promotion squash-merged the candidate onto whatever main was at merge time, so main code changed since the candidate's base (with the certificate unchanged) produced a tree no gate had qualified. Promotion now pushes the qualified commit to main without force, once its checks pass. The push succeeds only while main is still the commit the candidate was built on, so main becomes exactly the tested tree or nothing changes; the promote job pushes as the App so main's release workflow runs. When main has moved, the pair is requalified on the new main: candidate_action and the new requalification_target treat a promotable target built on an earlier main commit (the published attempt's key) like one compared against an earlier certificate, once per main commit and never while another requalification is in flight. Medium: one passing check could let a merge through. The gate now needs every check run on the qualified commit finished as success, skipped or neutral, and every pull request workflow run for it finished successfully, including the code-changes workflow that runs the tests; a run still registering its jobs, none reported, or an unreadable list waits. Medium: requalification and recovery dispatches named only the model and data versions, so resolve could pick another core and qualify a different pair while the original stayed the stale target. Dispatches now pass core_version and pair_id; resolve pins the core and fails the run, qualifying nothing, if the versions resolve to another pair. Medium: a mapping flag the counters stated with no flagged row became reviewable. It now fails. Lows: every early failure (unbound, rejected, failed, contradictory) records the policy id, its digest and the failure as the regression; the property test checks each verdict against an independent restatement of the decision record and a source-row zero total is reviewed, not failed; the policy text says a move below the bound raises no numerical flag but is reviewed if another flag fires. Mutation: the reviewer's three survivors, four for the check gate, three for the stale-base rule and the counted mapping flag are each killed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 47 ++- scripts/check_uk_candidate.py | 163 ++++++--- .../data/qualification/replay_policy.md | 6 +- src/policyengine/data/qualification/uk.json | 2 +- src/policyengine/provenance/qualification.py | 93 ++--- .../provenance/uk_qualification.py | 117 ++++++- tests/test_qualification_contracts.py | 85 +++++ tests/test_uk_qualification_pipeline.py | 317 +++++++++++++++--- tests/test_uk_qualification_workflow.py | 36 +- 9 files changed, 686 insertions(+), 180 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 02293de1d..60b7f3bbc 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -7,8 +7,10 @@ # mandatory gates on that exact commit, replayed by policyengine-scorecard, # and recorded in the uk-qualification-ledger branch. Promotion is # serialized: it readies the newest promotable candidate PR that advances -# main, closes superseded ones, and (cos d1344) merges it at its qualified -# commit once every check on it has passed. +# main, closes superseded ones, and (cos d1344) fast-forwards main to its +# qualified commit once every check on it has passed, which succeeds only +# while main is still the commit it was built on; otherwise the catch-up +# requalifies it on current main. # # Triggers: a policyengine-uk publish or uk-data release (repository # dispatch), a two-hourly catch-up, or a manual run. A manual run with @@ -29,6 +31,12 @@ on: data_version: description: uk-data release (default newest) required: false + core_version: + description: policyengine-core version (default the certified one if the model accepts it) + required: false + pair_id: + description: The exact pair to qualify; the run fails if the versions resolve to another + required: false force: description: Qualify even if the ledger already has a terminal receipt type: boolean @@ -104,6 +112,8 @@ jobs: PAYLOAD_VERSION: ${{ github.event.client_payload.version }} INPUT_MODEL: ${{ inputs.model_version }} INPUT_DATA: ${{ inputs.data_version }} + INPUT_CORE: ${{ inputs.core_version }} + INPUT_PAIR_ID: ${{ inputs.pair_id }} INPUT_FORCE: ${{ inputs.force }} run: | model="${INPUT_MODEL:-}" @@ -116,6 +126,8 @@ jobs: args=(resolve --out candidate.json --ledger ledger) [[ -n "$model" ]] && args+=(--model-version "$model") [[ -n "$data" ]] && args+=(--data-version "$data") + [[ -n "${INPUT_CORE:-}" ]] && args+=(--core-version "$INPUT_CORE") + [[ -n "${INPUT_PAIR_ID:-}" ]] && args+=(--pair-id "$INPUT_PAIR_ID") [[ "${INPUT_FORCE:-false}" == "true" ]] && args+=(--force) python scripts/check_uk_candidate.py "${args[@]}" - uses: actions/upload-artifact@v4 @@ -400,13 +412,16 @@ jobs: from pathlib import Path for line in Path("recoverable.jsonl").read_text().splitlines(): - pair = json.loads(line)["pair"] + entry = json.loads(line) + pair, pair_id = entry["pair"], entry["pair_id"] subprocess.run( [ "gh", "workflow", "run", "certify-uk-release.yaml", "--repo", os.environ["GITHUB_REPOSITORY"], "--ref", "main", "-f", f"model_version={pair['model']['version']}", "-f", f"data_version={pair['data']['release_version']}", + "-f", f"core_version={pair['core']['version']}", + "-f", f"pair_id={pair_id}", "-f", "force=false", ], check=True, @@ -427,9 +442,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python scripts/check_uk_candidate.py refresh-pr-checks --ledger ledger - # Automatic promotion needs the target qualified against main's current - # certificate. A run without force requalifies it once per main commit; - # repeated catch-ups on the same commit are deduplicated as duplicates. + # Automatic promotion needs the target built on current main and compared + # against main's certificate. A run without force requalifies it once per + # main commit, one at a time; repeated catch-ups are duplicates. - name: Requalify a promotable target qualified against an earlier certificate env: GH_TOKEN: ${{ github.token }} @@ -442,13 +457,16 @@ jobs: from pathlib import Path for line in Path("requalify.jsonl").read_text().splitlines(): - pair = json.loads(line)["pair"] + entry = json.loads(line) + pair, pair_id = entry["pair"], entry["pair_id"] subprocess.run( [ "gh", "workflow", "run", "certify-uk-release.yaml", "--repo", os.environ["GITHUB_REPOSITORY"], "--ref", "main", "-f", f"model_version={pair['model']['version']}", "-f", f"data_version={pair['data']['release_version']}", + "-f", f"core_version={pair['core']['version']}", + "-f", f"pair_id={pair_id}", "-f", "force=false", ], check=True, @@ -502,10 +520,19 @@ jobs: group: promote-uk cancel-in-progress: false steps: + # Promotion pushes main as the App, so the push starts main's release + # workflow; a push with the job's own token would not. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} - uses: astral-sh/setup-uv@v8.1.0 with: version: ${{ env.UV_VERSION }} @@ -514,12 +541,6 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install policyengine without country models run: uv pip install --system -e . - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Promote the newest qualified pair that advances main env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index a87160769..66ff7ee2f 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -82,6 +82,7 @@ previous_certificate, read_replay, replay_dispatch_payload, + requalification_target, resolve_candidate, stage_attempt, write_gate_results, @@ -90,6 +91,8 @@ BUNDLE_PATH = REPO_ROOT / "src" / "policyengine" / "data" / "bundle" / "manifest.json" CONFIG_PATH = REPO_ROOT / "src" / "policyengine" / "data" / "qualification" / "uk.json" TIMEOUT = 60 +#: The pull request workflow that runs the test suite on a candidate. +CODE_CHANGES_WORKFLOW = ".github/workflows/pr_code_changes.yaml" def utc_now() -> str: @@ -216,7 +219,17 @@ def cmd_resolve(args: argparse.Namespace) -> int: hf_file=hf_file, model_version=args.model_version or None, data_version=args.data_version or None, + core_version=args.core_version or None, ) + if args.pair_id and resolved.pair.pair_id != args.pair_id: + # A requalification or recovery names one exact pair. Resolving any + # other (a moved data tag, another core) must not qualify it instead. + print( + f"::error::resolved pair {resolved.pair.pair_id[:12]} " + f"({resolved.pair.order_key}), not the requested {args.pair_id[:12]}" + ) + set_output(action="mismatch", pair_id=resolved.pair.pair_id) + return 1 base_commit = git("rev-parse", "HEAD") resolved_at = utc_now() action, reason = candidate_action( @@ -494,16 +507,20 @@ def cmd_recoverable(args: argparse.Namespace) -> int: def cmd_requalify(args: argparse.Namespace) -> int: - """Print the promotion target's pair when its baseline is stale.""" + """Print the promotion target's pair when it must be requalified: its + qualification compared against an earlier certificate, or was built on + an earlier main commit than the one checked out.""" bundle = json.loads(BUNDLE_PATH.read_text()) problems: list[str] = [] receipts = latest_receipts(Path(args.ledger), problems=problems) for problem in problems: print(f"::warning::{problem}", file=sys.stderr) - plan = plan_promotion(receipts, bundle, {}) - if plan.requalify is not None: - print(json.dumps({"pair": plan.requalify.canonical_payload()})) + pair = requalification_target( + receipts, bundle, Path(args.ledger), git("rev-parse", "HEAD") + ) + if pair is not None: + print(json.dumps({"pair": pair.canonical_payload(), "pair_id": pair.pair_id})) return 0 @@ -622,67 +639,103 @@ def cmd_promote(args: argparse.Namespace) -> int: check=False, ) if config()["promotion"]["automatic"]: - # main is not branch-protected and the repository does not allow - # auto-merge, so merge only once every check on the qualified - # head has finished without failing; later runs retry. GitHub - # refuses the merge if the branch moved off the qualified commit. - waiting = _unfinished_checks(plan.ready_branch) - if waiting: - print( - f"automatic promotion waits for {plan.ready_branch}: " - + ", ".join(waiting) - ) - else: - subprocess.run( - [ - "gh", - "pr", - "merge", - plan.ready_branch, - "--repo", - LEDGER_REPOSITORY, - "--squash", - "--match-head-commit", - plan.ready_commit, - ], - check=False, - ) + _promote_by_fast_forward(plan.ready_branch, plan.ready_commit) return 0 -def _unfinished_checks(branch: str) -> list[str]: - """Checks on ``branch``'s pull request that have not passed. +def _promote_by_fast_forward(branch: str, commit: str) -> bool: + """Make main the qualified commit, exactly, once its checks have passed. - A skipped job counts as passed; a pending, failed or cancelled one does - not, and neither does a pull request reporting no checks at all. + The commit's parent is the main commit it was built on. A push that is + not forced succeeds only while main is still that parent, so main becomes + exactly the tested tree or nothing changes; when main has moved, the + catch-up requalifies the pair on the new main. GitHub then records the + candidate pull request as merged. """ - listing = subprocess.run( - [ - "gh", - "pr", - "checks", - branch, - "--repo", - LEDGER_REPOSITORY, - "--json", - "name,bucket", - ], + waiting = _unfinished_checks(commit) + if waiting: + print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) + return False + fetched = subprocess.run( + ["git", "fetch", "origin", f"refs/heads/{branch}"], check=False, capture_output=True, text=True, ) - try: - checks = json.loads(listing.stdout or "[]") - except json.JSONDecodeError: - return ["the check list could not be read"] - if not checks: - return ["no checks reported yet"] - return sorted( - f"{check.get('name')} ({check.get('bucket')})" - for check in checks - if check.get("bucket") not in ("pass", "skipping") + head = subprocess.run( + ["git", "rev-parse", "FETCH_HEAD"], check=False, capture_output=True, text=True + ).stdout.strip() + if fetched.returncode != 0 or head != commit: + print(f"automatic promotion skipped: {branch} is not at {commit[:12]}") + return False + pushed = subprocess.run( + ["git", "push", "origin", f"{commit}:refs/heads/main"], + check=False, + capture_output=True, + text=True, + ) + if pushed.returncode != 0: + print( + f"automatic promotion skipped: main has moved since {branch}'s base; " + "the pair is requalified on current main" + ) + return False + print(f"promoted {branch} at {commit[:12]} to main") + return True + + +def _unfinished_checks(commit: str) -> list[str]: + """Why the qualified commit's pull request checks are not complete. + + Every check run on the commit must have finished as success, skipped or + neutral, and every pull request workflow run for it must have finished + successfully, including the code-changes workflow that runs the tests: + a run still registering its jobs is unfinished, so one early passing + check never lets a merge through. Nothing reported at all also waits. + """ + + def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: + result = subprocess.run( + ["gh", "api", path, "--paginate", "--jq", jq], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + try: + return [json.loads(line) for line in result.stdout.splitlines() if line] + except json.JSONDecodeError: + return None + + checks = listing( + f"repos/{LEDGER_REPOSITORY}/commits/{commit}/check-runs?per_page=100", + ".check_runs[] | {name, status, conclusion} | tojson", + ) + runs = listing( + f"repos/{LEDGER_REPOSITORY}/actions/runs" + f"?head_sha={commit}&event=pull_request&per_page=100", + ".workflow_runs[] | {path, status, conclusion} | tojson", ) + if checks is None or runs is None: + return ["the checks could not be read"] + problems = [ + f"check {check.get('name')} is {check.get('conclusion') or check.get('status')}" + for check in checks + if check.get("status") != "completed" + or check.get("conclusion") not in ("success", "skipped", "neutral") + ] + problems += [ + f"workflow {run.get('path')} is {run.get('conclusion') or run.get('status')}" + for run in runs + if run.get("status") != "completed" or run.get("conclusion") != "success" + ] + if not any(run.get("path") == CODE_CHANGES_WORKFLOW for run in runs): + problems.append(f"no {CODE_CHANGES_WORKFLOW} run yet") + if not checks: + problems.append("no checks reported yet") + return sorted(problems) def cmd_accept(args: argparse.Namespace) -> int: @@ -715,6 +768,8 @@ def parser() -> argparse.ArgumentParser: resolve = commands.add_parser("resolve") resolve.add_argument("--model-version") resolve.add_argument("--data-version") + resolve.add_argument("--core-version") + resolve.add_argument("--pair-id") resolve.add_argument("--ledger") resolve.add_argument("--force", action="store_true") resolve.add_argument("--out", required=True) diff --git a/src/policyengine/data/qualification/replay_policy.md b/src/policyengine/data/qualification/replay_policy.md index 951be77b6..3e5efab05 100644 --- a/src/policyengine/data/qualification/replay_policy.md +++ b/src/policyengine/data/qualification/replay_policy.md @@ -16,7 +16,8 @@ comparison base. It sends every other flagged move to a named reviewer. - A rejected request, `no_supported_years`, integrity `failed` (a crash or missing outputs, persistent or not), or a receipt not bound to the request. -- A mapping flag. +- A mapping flag, whether a flagged row carries it or only the receipt's + counters state it. - A lost result: a coverage row with a base value and no candidate value. - A lost response, unless it is a known gap: - a measure-year the candidate lists as inert; @@ -52,4 +53,5 @@ review: - a receipt with no diff against a certified variant. Unsupported years alone never fail. A move below the diff's bound raises -no flag and is not reviewed. +no numerical flag; it is reviewed only if another flag, such as a structural +one, fires on it. diff --git a/src/policyengine/data/qualification/uk.json b/src/policyengine/data/qualification/uk.json index eaa7527e4..99538fad7 100644 --- a/src/policyengine/data/qualification/uk.json +++ b/src/policyengine/data/qualification/uk.json @@ -35,6 +35,6 @@ }, "promotion": { "automatic": true, - "decision": "cos d1344 (15 October 2026): automatic promotion is on once policyengine.py#574 lands on its gates. The promote job merges the newest promotable candidate PR that advances main, at its qualified commit, once every check on it has passed. A needs-review receipt is promoted only after a named reviewer accepts that exact receipt." + "decision": "cos d1344 (15 October 2026): automatic promotion is on once policyengine.py#574 lands on its gates. The promote job fast-forwards main to the newest promotable candidate's qualified commit once every check on it has passed, which succeeds only while main is still the commit the candidate was built on; otherwise the candidate is requalified on current main. A needs-review receipt is promoted only after a named reviewer accepts that exact receipt." } } diff --git a/src/policyengine/provenance/qualification.py b/src/policyengine/provenance/qualification.py index 9ca873400..dfe383a55 100644 --- a/src/policyengine/provenance/qualification.py +++ b/src/policyengine/provenance/qualification.py @@ -809,38 +809,53 @@ def evaluate_replay_receipt( "receipt_commit": status.receipt_commit, "status": receipt.status, "integrity": receipt.integrity.status, + "policy": REPLAY_POLICY_ID, + "policy_sha256": replay_policy_sha256(), + "known_gaps": [], + "regressions": [], + "review_items": [], } - if problems: + + def fail_early(kind: str, summary: str, **extra: Any) -> GateResult: + """A failure decided before the receipt's contents are judged; the + policy record lists it as the regression.""" + return GateResult( gate_id="replay", status="fail", - summary="Replay receipt is not bound to this request: " - + "; ".join(problems), - details={**details, "binding_problems": problems}, + summary=summary, + details={ + **details, + **extra, + "regressions": [{"kind": kind, "why": summary}], + }, + ) + + if problems: + return fail_early( + "unbound", + "Replay receipt is not bound to this request: " + "; ".join(problems), + binding_problems=problems, ) if receipt.status == "rejected": assert receipt.rejection is not None - return GateResult( - gate_id="replay", - status="fail", - summary=f"Replay rejected the candidate ({receipt.rejection.code}): {receipt.rejection.detail}", - details={**details, "rejection": receipt.rejection.document()}, + return fail_early( + "rejected", + f"Replay rejected the candidate ({receipt.rejection.code}): " + f"{receipt.rejection.detail}", + rejection=receipt.rejection.document(), ) execution = receipt.execution if execution is None: codes = sorted({failure.code for failure in receipt.integrity.failures}) - return GateResult( - gate_id="replay", - status="fail", - summary="Replay failed before the candidate was identified: " + return fail_early( + "failed", + "Replay failed before the candidate was identified: " + (", ".join(codes) or "no failure codes"), - details={ - **details, - "integrity_failures": [ - failure.document() for failure in receipt.integrity.failures - ], - }, + integrity_failures=[ + failure.document() for failure in receipt.integrity.failures + ], ) candidate = request.candidate mismatches: list[str] = [] @@ -884,27 +899,18 @@ def evaluate_replay_receipt( if execution.release_bundle != candidate.expected_release_bundle: mismatches.append("release bundle differs from the expected release bundle") if mismatches: - return GateResult( - gate_id="replay", - status="fail", - summary="Replay ran something other than the candidate: " - + "; ".join(mismatches), - details={**details, "execution_mismatches": mismatches}, + return fail_early( + "unbound", + "Replay ran something other than the candidate: " + "; ".join(mismatches), + execution_mismatches=mismatches, ) if receipt.status == "failed" or receipt.integrity.status == "failed": codes = sorted({failure.code for failure in receipt.integrity.failures}) - return GateResult( - gate_id="replay", - status="fail", - summary="Replay integrity failed: " - + (", ".join(codes) or "no failure codes"), - details={ - **details, - "integrity_failures": [ - f.document() for f in receipt.integrity.failures - ], - }, + return fail_early( + "failed", + "Replay integrity failed: " + (", ".join(codes) or "no failure codes"), + integrity_failures=[f.document() for f in receipt.integrity.failures], ) corpus = receipt.corpus assert corpus is not None @@ -972,12 +978,11 @@ def evaluate_replay_receipt( f"but the receipt compares with {base}" ) if contradictions: - return GateResult( - gate_id="replay", - status="fail", - summary="Replay receipt contradicts itself or the request: " + return fail_early( + "contradiction", + "Replay receipt contradicts itself or the request: " + "; ".join(contradictions), - details={**details, "contradictions": contradictions}, + contradictions=contradictions, ) # Flags come from every place the receipt can state them, never from the # summary counters alone: a flag a counter states but no row carries @@ -1067,7 +1072,11 @@ def item(kind: str, row: Any = None, **fields: Any) -> dict[str, Any]: kind = "flagged_move" review_items.append(item(kind, row)) for kind in sorted(present - row_counts.keys()): - review_items.append(item("counted_flag", flag=kind, count=counts.get(kind, 0))) + # A flag the counters state but no row carries: a blocking kind still + # fails, so a reviewer can never accept it. + (regressions if kind in BLOCKING_REPLAY_FLAGS else review_items).append( + item("counted_flag", flag=kind, count=counts.get(kind, 0)) + ) details.update( { "policy": REPLAY_POLICY_ID, diff --git a/src/policyengine/provenance/uk_qualification.py b/src/policyengine/provenance/uk_qualification.py index aa5fc948f..dda6f6803 100644 --- a/src/policyengine/provenance/uk_qualification.py +++ b/src/policyengine/provenance/uk_qualification.py @@ -235,8 +235,13 @@ def resolve_candidate( hf_file: Callable[[str, str, str], bytes], model_version: Optional[str] = None, data_version: Optional[str] = None, + core_version: Optional[str] = None, ) -> ResolvedCandidate: - """Resolve the candidate pair from PyPI and Hugging Face.""" + """Resolve the candidate pair from PyPI and Hugging Face. + + ``core_version`` pins the core, as a requalification of an exact pair + must; otherwise the certified core is kept if the model accepts it. + """ model_project = pypi_project(UK_MODEL_PACKAGE) releases = model_project["releases"] @@ -248,9 +253,17 @@ def resolve_candidate( requires_core = core_requirement(model_release["info"].get("requires_dist") or []) core_project = pypi_project(CORE_PACKAGE) - core_version = choose_core_version( - current_core, requires_core, _stable_versions(core_project["releases"]) - ) + if core_version is None: + core_version = choose_core_version( + current_core, requires_core, _stable_versions(core_project["releases"]) + ) + elif core_version not in core_project["releases"]: + raise ValueError(f"{CORE_PACKAGE} {core_version} is not on PyPI.") + elif Version(core_version) not in SpecifierSet(requires_core): + raise ValueError( + f"{UK_MODEL_PACKAGE} {model_version} requires {CORE_PACKAGE} " + f"{requires_core}, not {core_version}." + ) core_wheel = _wheel( core_project["releases"][core_version], CORE_PACKAGE, core_version ) @@ -407,22 +420,25 @@ def candidate_action( ) ): return "qualify", "recoverable abandoned staged attempt" - if ( + stale = ( receipt is not None and receipt.promotable and dominates(pair.order_key, current) - and receipt.previous_certificate != previous_certificate(bundle) - ): - # Promotion needs a qualification against main's current - # certificate. Requalify once per main commit, without force. + and stale_qualification(receipt, bundle, pair_dir, base_commit) + ) + if stale: + # Promotion fast-forwards main to the qualified commit, so it needs a + # qualification built on current main against main's certificate. + # Requalify once per main commit, without force, and one at a time. if attempt is not None: return "duplicate", ( f"a requalification from base {base_commit[:12]} exists" ) - return "qualify", ( - "promotable against a superseded baseline; requalifying against " - "current main" - ) + if _requalification_in_flight( + pair_dir, now=now, owner_is_failed=owner_is_failed + ): + return "duplicate", "a requalification is already in flight" + return "qualify", f"promotable but {stale}; requalifying against current main" if receipt is not None and receipt.verdict != "incomplete": return "duplicate", f"pair already {receipt.verdict} in the ledger" if attempt is not None: @@ -430,6 +446,81 @@ def candidate_action( return "qualify", "new pair, or an incomplete pair on a new base commit" +def qualified_base(pair_dir: Path) -> Optional[str]: + """The main commit prefix the pair's published receipt was built on. + + An attempt's key is its base commit's first 12 characters, with ``-`` + for a repeat on the same base. Pairs published before + ``latest_attempt.json`` existed have no recorded base. + """ + + pointer = pair_dir / "latest_attempt.json" + if not pointer.exists(): + return None + return str(read_json(pointer)["attempt_key"]).split("-")[0] + + +def stale_qualification( + receipt: QualificationReceipt, + bundle: Mapping[str, Any], + pair_dir: Path, + main_commit: str, +) -> Optional[str]: + """Why a promotable receipt cannot be promoted onto current main, if so. + + Promotion fast-forwards main to the qualified commit, which needs the + qualification to be built on current main and compared against main's + certificate. + """ + + if receipt.previous_certificate != previous_certificate(bundle): + return "qualified against a superseded baseline" + base = qualified_base(pair_dir) + if base is not None and base != main_commit[:12]: + return f"qualified on main {base}, not the current {main_commit[:12]}" + return None + + +def _requalification_in_flight( + pair_dir: Path, + *, + now: Optional[str], + owner_is_failed: Optional[Callable[[str], bool]], +) -> bool: + """A staged attempt newer than the published one that is still running.""" + + stages = list((pair_dir / "attempts").glob("*/staging.json")) + if not stages: + return False + newest = max(stages, key=lambda path: int(read_json(path)["sequence"])).parent + pointer = pair_dir / "latest_attempt.json" + published = read_json(pointer)["attempt_key"] if pointer.exists() else None + return ( + newest.name != published + and not (newest / "qualification_receipt.json").exists() + and not attempt_is_recoverable(newest, now=now, owner_is_failed=owner_is_failed) + ) + + +def requalification_target( + receipts: Iterable[QualificationReceipt], + bundle: Mapping[str, Any], + ledger_root: Path, + main_commit: str, +) -> Optional[CandidatePair]: + """The promotion target, when it must be requalified on current main.""" + + target = select_promotion_target(list(receipts), certified_order_key(bundle)) + if target is None: + return None + pair_dir = pair_directory(ledger_root, "uk", target.pair_id) + return ( + target.pair + if stale_qualification(target, bundle, pair_dir, main_commit) + else None + ) + + # --- Preparing the candidate commit ----------------------------------------- #: Test files that pin the certified UK pair literally. Promotion regenerates diff --git a/tests/test_qualification_contracts.py b/tests/test_qualification_contracts.py index 3c781379e..9b55e534a 100644 --- a/tests/test_qualification_contracts.py +++ b/tests/test_qualification_contracts.py @@ -669,6 +669,22 @@ def _receipt_with(rows=(), inert=(), complete=True, integrity="complete", counts "needs_review", {"flagged_move"}, ), + "£0 source row against a material base (not a measure-year total)": ( + [ + { + "flags": ["structural"], + "level": "source_row", + "key": "obr-row-1", + "base_gbp": 1e8, + "head_gbp": 0.0, + } + ], + (), + True, + "complete", + "needs_review", + {"flagged_move"}, + ), "numeric base result, missing candidate result": ( [ { @@ -833,6 +849,52 @@ def test_a_counted_flag_with_no_row_still_needs_review(): ] +def test_a_counted_mapping_flag_with_no_row_fails(): + """A blocking flag the counters state still fails, so no reviewer can + accept it.""" + + receipt = _receipt_with(counts={"mapping": 1}) + _validator("replay_receipt.v1.json").validate(receipt) + gate = _gate(receipt) + assert gate.status == "fail" + assert gate.details["regressions"] == [ + {"kind": "counted_flag", "flag": "mapping", "count": 1} + ] + + +def test_every_early_failure_carries_the_policy_record(): + import hashlib + from importlib.resources import files + + text = (files("policyengine.data.qualification") / "replay_policy.md").read_bytes() + clean = _receipt_with() + rejected = _rejected_receipt() + contradictory = _edit( + _clean_receipt(), + lambda r: r["corpus"]["events"][EVENT].update(artifacts=0), + ) + failed = copy.deepcopy(_with_requested_wheels(golden("replay_receipt_golden"))) + failed["status"] = "failed" + failed["integrity"] = { + "status": "failed", + "failures": [{"code": "construction_inert", "detail": "x"}], + } + unbound = copy.deepcopy(clean) + unbound["execution"]["dataset_sha256"] = "0" * 64 + cases = { + "rejected": _gate(rejected), + "contradiction": _gate(contradictory), + "failed": _gate(_sealed(failed)), + "unbound": _gate(_sealed(unbound)), + } + for kind, gate in cases.items(): + assert gate.status == "fail", kind + assert gate.details["policy"] == REPLAY_POLICY_ID + assert gate.details["policy_sha256"] == hashlib.sha256(text).hexdigest() + assert [r["kind"] for r in gate.details["regressions"]] == [kind], kind + assert gate.details["review_items"] == [] and gate.details["known_gaps"] == [] + + def test_the_gate_records_the_policy_it_applied(): import hashlib from importlib.resources import files @@ -940,8 +1002,31 @@ def judge(these): ) return _gate(receipt) + def oracle(these): + """The decision record's outcome, restated independently: a + regression fails; any other flagged row needs review.""" + + for row in these: + base, head = row["base_gbp"], row["head_gbp"] + if ( + "mapping" in row["flags"] + or ("coverage" in row["flags"] and base is not None and head is None) + or ( + row["level"] == "measure_year" + and head == 0 + and base is not None + and abs(base) >= 1_000_000 + ) + ): + return "fail" + if listed: + return "fail" + return "needs_review" if these else "pass" + before = judge(rows) after = judge([*rows, extra]) + assert before.status == oracle(rows), (rows, before.summary) + assert after.status == oracle([*rows, extra]), (extra, after.summary) assert _RANK[after.status] >= _RANK[before.status] for gate, these in ((before, rows), (after, [*rows, extra])): assert (gate.status == "fail") == bool(gate.details["regressions"]) diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 4454e8451..8183bf59f 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -127,6 +127,73 @@ def _resolver(**overrides): return resolve_candidate(**kwargs), manifest_bytes +def test_a_pinned_core_is_used_exactly_or_refused(): + """A requalification names its exact core: the certified one, if the + model accepts it, must not be chosen instead.""" + + resolved, _ = _resolver(model_version="2.126.8", core_version="3.34.1") + assert resolved.pair.core.version == "3.34.1" + assert _resolver(model_version="2.126.8")[0].pair.core.version == "3.33.0" + with pytest.raises(ValueError, match="not on PyPI"): + _resolver(model_version="2.126.8", core_version="9.9.9") + with pytest.raises(ValueError, match="requires policyengine-core"): + _resolver(model_version="2.126.6", core_version="3.33.0") + + +def test_resolve_refuses_a_pair_other_than_the_one_requested( + monkeypatch, tmp_path, capsys +): + """A requalification or recovery dispatch names one pair. If its versions + resolve to another (a moved data tag, another core), the run fails and + nothing else is qualified.""" + + import importlib.util + + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_pair_pin", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + pair = make_pair(model="2.200.0") + resolved = cli.ResolvedCandidate( + pair, + cli.WheelFile("m.whl", "https://example.org/m.whl", pair.model.wheel_sha256), + cli.WheelFile("c.whl", "https://example.org/c.whl", pair.core.wheel_sha256), + "hf://model/example@pin/manifest.json", + ">=3.33.0", + ) + seen = {} + monkeypatch.setattr( + cli, + "resolve_candidate", + lambda **kwargs: seen.update(kwargs) or resolved, + ) + monkeypatch.setattr(cli, "git", lambda *args: "a" * 40) + outputs = [] + monkeypatch.setattr(cli, "set_output", lambda **kwargs: outputs.append(kwargs)) + out = tmp_path / "candidate.json" + + def args(pair_id): + return SimpleNamespace( + model_version="2.200.0", + data_version="1.58.0", + core_version="3.34.1", + pair_id=pair_id, + ledger=str(tmp_path), + force=False, + out=str(out), + ) + + assert cli.cmd_resolve(args("0" * 64)) == 1 + assert seen["core_version"] == "3.34.1" + assert outputs[-1]["action"] == "mismatch" + assert not out.exists() + assert "not the requested" in capsys.readouterr().out + assert cli.cmd_resolve(args(pair.pair_id)) == 0 + assert json.loads(out.read_text())["pair_id"] == pair.pair_id + + def test_resolution_picks_newest_stable_and_keeps_an_accepted_core(): resolved, manifest_bytes = _resolver() pair = resolved.pair @@ -265,6 +332,63 @@ def action(base): ) +def test_a_target_built_on_an_earlier_main_requalifies_one_attempt_at_a_time( + tmp_path, +): + """Promotion fast-forwards main to the qualified commit, so a promotable + pair built on an earlier main commit is requalified on the current one: + once per main commit, and never while another requalification runs.""" + + from policyengine.provenance.qualification_ledger import attempt_directory + from policyengine.provenance.uk_qualification import ( + requalification_target, + stage_attempt, + ) + + pair = _promotable_pair_and_branch() + pair_dir = tmp_path / "uk" / pair.pair_id + qualified = _promotion_receipt(pair=pair) + _publish_as_attempt(tmp_path, pair, qualified, "a" * 12) + write_json( + pair_dir / "latest_attempt.json", {"attempt_key": "a" * 12, "sequence": 0} + ) + now = "2026-10-16T10:00:00Z" + + def action(base): + return candidate_action(pair, BUNDLE, tmp_path, base_commit=base * 40, now=now) + + assert action("a")[0] == "duplicate" + verdict, reason = action("b") + assert verdict == "qualify" and "qualified on main aaaaaaaaaaaa" in reason + assert requalification_target([qualified], BUNDLE, tmp_path, "b" * 40) == pair + assert requalification_target([qualified], BUNDLE, tmp_path, "a" * 40) is None + # A requalification from b runs; c arrives meanwhile and waits for it. + stage_attempt( + attempt_directory(tmp_path, "uk", pair.pair_id, "b" * 12), + {"pair": pair.canonical_payload(), "pair_id": pair.pair_id}, + HARNESS, + owner_run_id="7", + staged_at="2026-10-16T09:30:00Z", + ) + assert action("b")[0] == "duplicate" + verdict, reason = action("c") + assert verdict == "duplicate" and "in flight" in reason + # Once it has finished (its receipt published), c requalifies. + requalified = qualified.model_copy(update={"completed_at": "2026-10-16T11:00:00Z"}) + _publish_as_attempt(tmp_path, pair, requalified, "b" * 12) + write_json( + pair_dir / "latest_attempt.json", {"attempt_key": "b" * 12, "sequence": 1} + ) + assert action("b")[0] == "duplicate" + assert action("c")[0] == "qualify" + # A repeat key on the same base names the same base. + write_json( + pair_dir / "latest_attempt.json", + {"attempt_key": "c" * 12 + "-2", "sequence": 2}, + ) + assert requalification_target([requalified], BUNDLE, tmp_path, "c" * 40) is None + + def test_requalify_names_the_target_only_while_its_baseline_is_stale( monkeypatch, tmp_path, capsys ): @@ -285,15 +409,17 @@ def test_requalify_names_the_target_only_while_its_baseline_is_stale( ) } ) + monkeypatch.setattr(cli, "git", lambda *args: "a" * 40) for receipts, expected in (([stale], [stale.pair]), ([current], []), ([], [])): monkeypatch.setattr(cli, "latest_receipts", lambda root, r=receipts, **_: r) assert cli.cmd_requalify(argparse.Namespace(ledger=str(tmp_path))) == 0 printed = [ - json.loads(line)["pair"] - for line in capsys.readouterr().out.splitlines() - if line + json.loads(line) for line in capsys.readouterr().out.splitlines() if line + ] + assert printed == [ + {"pair": pair.canonical_payload(), "pair_id": pair.pair_id} + for pair in expected ] - assert printed == [pair.canonical_payload() for pair in expected] def test_incomplete_pairs_requalify_on_a_new_base_only(tmp_path): @@ -736,37 +862,90 @@ def test_a_branch_that_moved_off_the_qualified_commit_is_not_promoted(): CHECKS_PASSED = [ - {"name": "Lint", "bucket": "pass"}, - {"name": "Test (3.13)", "bucket": "pass"}, - {"name": "Docs", "bucket": "skipping"}, + {"name": "Lint", "status": "completed", "conclusion": "success"}, + {"name": "Test (3.13)", "status": "completed", "conclusion": "success"}, + {"name": "Docs", "status": "completed", "conclusion": "skipped"}, +] +RUNS_PASSED = [ + { + "path": ".github/workflows/pr_code_changes.yaml", + "status": "completed", + "conclusion": "success", + } ] -@pytest.mark.parametrize( - ("checks", "merges"), - [ - (CHECKS_PASSED, True), - ([*CHECKS_PASSED[:1], {"name": "Test (3.13)", "bucket": "pending"}], False), - ([*CHECKS_PASSED[:1], {"name": "Test (3.13)", "bucket": "fail"}], False), - ([{"name": "Test (3.13)", "bucket": "cancel"}], False), - ([], False), - (None, False), - ], - ids=["passed", "pending", "failed", "cancelled", "none", "unreadable"], -) -def test_promote_readies_and_merges_only_the_qualified_commit( - monkeypatch, tmp_path, checks, merges +def _check(name, status="completed", conclusion=None): + return {"name": name, "status": status, "conclusion": conclusion} + + +PROMOTION_CASES = { + "everything passed": (CHECKS_PASSED, RUNS_PASSED, True, 0, True), + "a test still running": ( + [*CHECKS_PASSED[:1], _check("Test (3.13)", "in_progress")], + RUNS_PASSED, + True, + 0, + False, + ), + "a test failed": ( + [*CHECKS_PASSED[:1], _check("Test (3.13)", conclusion="failure")], + RUNS_PASSED, + True, + 0, + False, + ), + "a test cancelled": ( + [_check("Test", conclusion="cancelled")], + RUNS_PASSED, + True, + 0, + False, + ), + "no checks yet": ([], RUNS_PASSED, True, 0, False), + "only lint so far, the test workflow still registering": ( + CHECKS_PASSED[:1], + [{**RUNS_PASSED[0], "status": "in_progress", "conclusion": None}], + True, + 0, + False, + ), + "the test workflow never ran": ( + CHECKS_PASSED, + [ + { + "path": ".github/workflows/pr_docs_changes.yaml", + "status": "completed", + "conclusion": "success", + } + ], + True, + 0, + False, + ), + "the checks could not be read": (None, RUNS_PASSED, True, 0, False), + "the branch moved": (CHECKS_PASSED, RUNS_PASSED, False, 0, False), + "main moved since the base": (CHECKS_PASSED, RUNS_PASSED, True, 1, False), +} + + +@pytest.mark.parametrize("case", sorted(PROMOTION_CASES)) +def test_promotion_fast_forwards_main_to_exactly_the_qualified_commit( + monkeypatch, tmp_path, capsys, case ): """main is not branch-protected and the repository does not allow - auto-merge, so automatic promotion merges the qualified head only once - every check on it has passed (skipped jobs aside), and never by - --auto.""" + auto-merge. Promotion pushes the qualified commit to main without force, + once every check and pull request workflow run on it has passed: the + push succeeds only while main is still the commit it was built on, so + main becomes exactly the tested tree or nothing changes.""" import argparse import importlib.util + checks, runs, branch_at_commit, push_code, promotes = PROMOTION_CASES[case] qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) branch = candidate_branch(qualified.pair) + commit = qualified.harness.git_commit spec = importlib.util.spec_from_file_location( "check_uk_candidate_for_test", Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", @@ -777,45 +956,73 @@ def test_promote_readies_and_merges_only_the_qualified_commit( def run(argv, **kwargs): calls.append(argv) - if argv[:3] == ["gh", "pr", "checks"]: - assert argv[3] == branch - stdout = "not json" if checks is None else json.dumps(checks) - return SimpleNamespace(returncode=0, stdout=stdout) - return SimpleNamespace(returncode=0, stdout="") + if argv[:2] == ["gh", "api"]: + items = checks if "check-runs" in argv[2] else runs + if "check-runs" in argv[2]: + assert f"commits/{commit}/" in argv[2] + else: + assert f"head_sha={commit}" in argv[2] + if items is None: + return SimpleNamespace(returncode=1, stdout="") + return SimpleNamespace( + returncode=0, stdout="".join(json.dumps(i) + "\n" for i in items) + ) + if argv[:2] == ["git", "rev-parse"]: + head = commit if branch_at_commit else "f" * 40 + return SimpleNamespace(returncode=0, stdout=head + "\n") + if argv[:2] == ["git", "push"]: + return SimpleNamespace(returncode=push_code, stdout="", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) - monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: "f" * 40}) + monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 - assert calls == [] + assert calls[0][:3] == ["gh", "pr", "ready"] and calls[0][3] == branch + pushes = [argv for argv in calls if argv[:2] == ["git", "push"]] + assert all("--force" not in argv and "-f" not in argv for argv in pushes) + assert not [argv for argv in calls if argv[:3] == ["gh", "pr", "merge"]] + reached_push = ( + checks is not None and promotes or case == "main moved since the base" + ) + if reached_push: + assert pushes == [["git", "push", "origin", f"{commit}:refs/heads/main"]] + else: + assert pushes == [] + out = capsys.readouterr().out + assert (f"promoted {branch}" in out) is promotes + if case == "main moved since the base": + assert "requalified on current main" in out + + +def test_without_automatic_promotion_the_target_is_only_readied(monkeypatch, tmp_path): + import argparse + import importlib.util + + qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) + branch = candidate_branch(qualified.pair) + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_manual", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + calls = [] + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr( cli, - "_open_candidate_branches", - lambda: {branch: qualified.harness.git_commit}, + "subprocess", + SimpleNamespace( + run=lambda argv, **k: ( + calls.append(argv) or SimpleNamespace(returncode=0, stdout="") + ) + ), ) - assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 - assert calls[0][:3] == ["gh", "pr", "ready"] and calls[0][3] == branch - merged = [argv for argv in calls if argv[:3] == ["gh", "pr", "merge"]] - if merges: - assert merged == [ - [ - "gh", - "pr", - "merge", - branch, - "--repo", - "PolicyEngine/policyengine.py", - "--squash", - "--match-head-commit", - qualified.harness.git_commit, - ] - ] - else: - assert merged == [] - # Without automatic promotion it only readies. - calls.clear() monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": False}}) + monkeypatch.setattr( + cli, "_open_candidate_branches", lambda: {branch: qualified.harness.git_commit} + ) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert [argv[:3] for argv in calls] == [["gh", "pr", "ready"]] @@ -1402,6 +1609,8 @@ def test_resolution_preserves_authorization_to_recover_forced_work( args = SimpleNamespace( model_version=None, data_version=None, + core_version=None, + pair_id=None, ledger=str(tmp_path), force=True, out=str(out), diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index bdb85ac5c..5a59769bf 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -118,6 +118,34 @@ def test_catch_up_requalifies_a_stale_target_after_finishing_receipts(): assert job["permissions"]["actions"] == "write" +def test_every_dispatch_names_the_exact_pair(): + """Recovery and requalification dispatch one pair: its versions, its core + and its id, which resolve checks, so another pair is never qualified in + its place.""" + + workflow = _workflow() + inputs = workflow[True]["workflow_dispatch"]["inputs"] + assert {"core_version", "pair_id"} <= set(inputs) + resolve = next( + step + for step in workflow["jobs"]["resolve"]["steps"] + if step.get("name") == "Resolve and deduplicate the candidate" + ) + assert '--core-version "$INPUT_CORE"' in resolve["run"] + assert '--pair-id "$INPUT_PAIR_ID"' in resolve["run"] + dispatches = [ + step["run"] + for step in workflow["jobs"]["catch-up"]["steps"] + if 'gh", "workflow", "run' in step.get("run", "") + or '"gh", "workflow", "run"' in step.get("run", "") + ] + assert len(dispatches) == 2 + for run in dispatches: + assert "core_version={pair['core']['version']}" in run + assert "pair_id={pair_id}" in run + assert 'entry["pair_id"]' in run + + def test_preparation_syncs_the_candidate_lock_before_using_it(): """Snapshots, the bundle check and staging must run in the candidate's environment: synced from the lock preparation regenerates, after it is @@ -297,7 +325,9 @@ def test_catch_up_dispatches_recovery_for_the_original_pair(tmp_path): ) pair = make_pair(model="2.200.0", data="1.58.9") candidate_path = tmp_path / "candidate.json" - candidate_path.write_text(json.dumps({"pair": pair.canonical_payload()})) + candidate_path.write_text( + json.dumps({"pair": pair.canonical_payload(), "pair_id": pair.pair_id}) + ) command_log = tmp_path / "commands.jsonl" binary_dir = tmp_path / "bin" binary_dir.mkdir() @@ -354,6 +384,10 @@ def test_catch_up_dispatches_recovery_for_the_original_pair(tmp_path): "-f", "data_version=1.58.9", "-f", + f"core_version={pair.core.version}", + "-f", + f"pair_id={pair.pair_id}", + "-f", "force=false", ] ] From d0c805c1f63636f310e0e5057f5b5cc406b1f4f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 00:18:20 -0400 Subject: [PATCH 06/18] Keep candidates draft unless their exact tree can land; test that tree Round 2 of the #635 review: High, a manual merge could still bypass qualification: promotion readied the target even when main had moved, and a squash merge then combined the pair with untested code. A candidate is now readied only while main is still the commit it was built on (so any merge of it is exactly the qualified tree); otherwise it is kept draft (readiness is undone) and requalified on current main. With automatic promotion it stays draft until its checks pass, is readied only for the push, and is returned to draft if the push fails. Medium, a non-forced push accepted main force-reset to an older ancestor: the push is now leased on the candidate's parent (--force-with-lease=refs/heads/main:), an atomic compare-and- swap. Medium, green pull-request checks described the candidate merged with an older main: preparation now dispatches the code-changes workflow on the candidate branch, testing the commit's own tree, and promotion requires that run. Medium, an older cancelled run blocked forever: the gate reads the newest run of each workflow and event, and the newest check of each name. refresh-pr-checks also reruns failed dispatched runs. Medium, a pair without latest_attempt.json was stranded: its base is the attempt whose own receipt is the published one, and an unknown base counts as stale. Medium, runs resolved on different main commits could both stage: no pair qualifies while another of its attempts is in flight, and the preparation lock rechecks eligibility against the ledger as it is now (still-eligible), with the resolved force recorded in the candidate. Ten mutants of these changes are killed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 50 ++- scripts/check_uk_candidate.py | 165 ++++++--- .../provenance/uk_qualification.py | 36 +- tests/test_uk_qualification_pipeline.py | 339 ++++++++++++------ tests/test_uk_qualification_workflow.py | 81 ++++- 5 files changed, 493 insertions(+), 178 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 60b7f3bbc..84166c437 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -145,6 +145,10 @@ jobs: # promotion can ready a replacement after checking the previous head. group: promote-uk cancel-in-progress: false + permissions: + contents: write + pull-requests: write + actions: write outputs: candidate_commit: ${{ steps.stage.outputs.candidate_commit }} duplicate: ${{ steps.duplicate.outputs.duplicate }} @@ -164,37 +168,36 @@ jobs: ref: ${{ needs.resolve.outputs.base_commit }} token: ${{ steps.app-token.outputs.token }} fetch-depth: 0 - # Another run may have staged this attempt while this one waited for - # the promotion concurrency group; never overwrite its evidence. - - name: Check for an attempt staged by another run - id: duplicate - env: - ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} - run: | - .github/qualification-ledger.sh checkout ledger - if [[ -d "$ATTEMPT" ]]; then - echo "duplicate=true" >> "$GITHUB_OUTPUT" - echo "Attempt already staged by another run; nothing to do." - else - echo "duplicate=false" >> "$GITHUB_OUTPUT" - fi - git worktree remove --force ledger - uses: actions/download-artifact@v4 - if: steps.duplicate.outputs.duplicate == 'false' with: name: candidate path: ${{ runner.temp }}/candidate - uses: astral-sh/setup-uv@v8.1.0 - if: steps.duplicate.outputs.duplicate == 'false' with: version: ${{ env.UV_VERSION }} - uses: actions/setup-python@v6 - if: steps.duplicate.outputs.duplicate == 'false' with: python-version: ${{ env.PYTHON_VERSION }} - name: Install the base environment - if: steps.duplicate.outputs.duplicate == 'false' run: uv sync --frozen --extra uk --extra dev --python ${{ env.PYTHON_VERSION }} + # Another run may have staged this attempt, or another attempt for the + # pair from another main commit, while this one waited for the + # promotion concurrency group: recheck against the ledger as it is now, + # and never overwrite another run's evidence. + - name: Check for an attempt staged by another run + id: duplicate + env: + ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + run: | + .github/qualification-ledger.sh checkout ledger + if [[ -d "$ATTEMPT" ]]; then + echo "duplicate=true" >> "$GITHUB_OUTPUT" + echo "Attempt already staged by another run; nothing to do." + else + uv run --no-sync python scripts/check_uk_candidate.py still-eligible \ + --candidate "$RUNNER_TEMP/candidate/candidate.json" --ledger ledger + fi + git worktree remove --force ledger - name: Certify the pair and regenerate pins, lock, TROs and constants if: steps.duplicate.outputs.duplicate == 'false' env: @@ -238,6 +241,15 @@ jobs: -c user.email="qualification@policyengine.org" \ commit -q -m "Certify UK candidate pair ${PAIR_ID:0:12} for qualification" git push --force origin "HEAD:refs/heads/$BRANCH" + # Pull request runs test the candidate merged with main as it was then. + # Promotion needs the commit's own tree tested, so run the code-changes + # workflow on the candidate branch itself. + - name: Test the candidate commit's own tree + if: steps.duplicate.outputs.duplicate == 'false' + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ needs.resolve.outputs.branch }} + run: gh workflow run pr_code_changes.yaml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH" - name: Stage the attempt in the ledger id: stage if: steps.duplicate.outputs.duplicate == 'false' diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 66ff7ee2f..19516679f 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -261,6 +261,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: "branch": candidate_branch(resolved.pair), "action": action, "reason": reason, + "force": bool(args.force), "previous_certificate": previous_certificate(bundle).canonical_payload(), } if action == "qualify" and args.ledger: @@ -284,6 +285,26 @@ def cmd_resolve(args: argparse.Namespace) -> int: return 0 +def cmd_still_eligible(args: argparse.Namespace) -> int: + """Whether a resolved candidate should still be qualified, against the + ledger as it is now: run under the preparation lock, so two runs that + resolved on different main commits never both stage an attempt.""" + + payload = json.loads(Path(args.candidate).read_text()) + action, reason = candidate_action( + CandidatePair.model_validate(payload["pair"]), + json.loads(BUNDLE_PATH.read_text()), + Path(args.ledger), + base_commit=payload["base_commit"], + force=bool(payload.get("force")), + now=utc_now(), + owner_is_failed=owner_run_failed, + ) + print(f"{action}: {reason}") + set_output(duplicate="false" if action == "qualify" else "true") + return 0 + + def cmd_prepare(args: argparse.Namespace) -> int: payload = json.loads(Path(args.candidate).read_text()) prepared = prepare_candidate_commit( @@ -565,8 +586,7 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: [ "gh", "api", - f"repos/{LEDGER_REPOSITORY}/actions/runs" - f"?head_sha={head}&event=pull_request&per_page=100", + f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={head}&per_page=100", "--paginate", "--jq", ".workflow_runs[] | {id, head_sha, event, status, conclusion} | tojson", @@ -579,7 +599,7 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: run = json.loads(line) if ( run["head_sha"] != head - or run["event"] != "pull_request" + or run["event"] not in ("pull_request", "workflow_dispatch") or run["status"] != "completed" or run["conclusion"] not in {"failure", "timed_out"} ): @@ -634,50 +654,77 @@ def cmd_promote(args: argparse.Namespace) -> int: check=False, ) if plan.ready_branch is not None and plan.ready_commit is not None: - subprocess.run( - ["gh", "pr", "ready", plan.ready_branch, "--repo", LEDGER_REPOSITORY], - check=False, + _promote( + plan.ready_branch, + plan.ready_commit, + automatic=config()["promotion"]["automatic"], ) - if config()["promotion"]["automatic"]: - _promote_by_fast_forward(plan.ready_branch, plan.ready_commit) return 0 -def _promote_by_fast_forward(branch: str, commit: str) -> bool: - """Make main the qualified commit, exactly, once its checks have passed. +def _set_ready(branch: str, ready: bool) -> None: + subprocess.run( + ["gh", "pr", "ready", branch, "--repo", LEDGER_REPOSITORY] + + ([] if ready else ["--undo"]), + check=False, + ) + + +def _promote(branch: str, commit: str, *, automatic: bool) -> bool: + """Make main exactly the qualified commit, or change nothing. - The commit's parent is the main commit it was built on. A push that is - not forced succeeds only while main is still that parent, so main becomes - exactly the tested tree or nothing changes; when main has moved, the - catch-up requalifies the pair on the new main. GitHub then records the - candidate pull request as merged. + The qualified commit's parent is the main commit it was built on. Only + while main is still that commit does a merge of the candidate equal the + qualified tree, so only then is its pull request readied; otherwise it + is kept draft (no merge can combine it with other code) and the catch-up + requalifies it on current main. With automatic promotion the pull + request stays draft until every check on the commit's own tree has + passed, and main is then moved to the commit by a push leased on that + parent: if main is anything else at that instant, nothing changes. + GitHub then records the pull request as merged. """ - waiting = _unfinished_checks(commit) - if waiting: - print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) - return False fetched = subprocess.run( ["git", "fetch", "origin", f"refs/heads/{branch}"], check=False, capture_output=True, text=True, ) - head = subprocess.run( - ["git", "rev-parse", "FETCH_HEAD"], check=False, capture_output=True, text=True - ).stdout.strip() - if fetched.returncode != 0 or head != commit: - print(f"automatic promotion skipped: {branch} is not at {commit[:12]}") + head = _git_out("rev-parse", "FETCH_HEAD") if fetched.returncode == 0 else "" + parent = _git_out("rev-parse", f"{commit}^") if head == commit else "" + listed = _git_out("ls-remote", "origin", "refs/heads/main").split() + main = listed[0] if listed else "" + if head != commit or not parent or parent != main: + _set_ready(branch, False) + print( + f"not promotable now: {branch} is not the qualified commit " + f"{commit[:12]} built on current main; it is requalified on current main" + ) + return False + if not automatic: + _set_ready(branch, True) + return False + waiting = _unfinished_checks(commit) + if waiting: + print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) return False + _set_ready(branch, True) pushed = subprocess.run( - ["git", "push", "origin", f"{commit}:refs/heads/main"], + [ + "git", + "push", + f"--force-with-lease=refs/heads/main:{parent}", + "origin", + f"{commit}:refs/heads/main", + ], check=False, capture_output=True, text=True, ) if pushed.returncode != 0: + _set_ready(branch, False) print( - f"automatic promotion skipped: main has moved since {branch}'s base; " + f"automatic promotion skipped: main is no longer {parent[:12]}; " "the pair is requalified on current main" ) return False @@ -685,14 +732,22 @@ def _promote_by_fast_forward(branch: str, commit: str) -> bool: return True -def _unfinished_checks(commit: str) -> list[str]: - """Why the qualified commit's pull request checks are not complete. +def _git_out(*args: str) -> str: + result = subprocess.run(["git", *args], check=False, capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else "" - Every check run on the commit must have finished as success, skipped or - neutral, and every pull request workflow run for it must have finished - successfully, including the code-changes workflow that runs the tests: - a run still registering its jobs is unfinished, so one early passing - check never lets a merge through. Nothing reported at all also waits. + +def _unfinished_checks(commit: str) -> list[str]: + """Why the qualified commit's checks do not yet show its own tree passing. + + Pull request runs test the candidate merged with main as it was then; + the run of the code-changes workflow that the qualification dispatches on + the candidate branch tests the commit's own tree, and must exist. For + each workflow and event, the newest run must have finished successfully, + and for each check name the newest check must have finished as success, + skipped or neutral, so an older cancelled or failed execution that a + newer one replaced never blocks. A run still registering its jobs, none + reported, or an unreadable list waits. """ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: @@ -711,28 +766,41 @@ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: checks = listing( f"repos/{LEDGER_REPOSITORY}/commits/{commit}/check-runs?per_page=100", - ".check_runs[] | {name, status, conclusion} | tojson", + ".check_runs[] | {id, name, status, conclusion} | tojson", ) runs = listing( - f"repos/{LEDGER_REPOSITORY}/actions/runs" - f"?head_sha={commit}&event=pull_request&per_page=100", - ".workflow_runs[] | {path, status, conclusion} | tojson", + f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={commit}&per_page=100", + ".workflow_runs[] | {id, path, event, status, conclusion} | tojson", ) if checks is None or runs is None: return ["the checks could not be read"] + newest_runs: dict[tuple[Any, Any], dict[str, Any]] = {} + for run in runs: + key = (run.get("path"), run.get("event")) + if key not in newest_runs or run.get("id", 0) > newest_runs[key].get("id", 0): + newest_runs[key] = run + newest_checks: dict[Any, dict[str, Any]] = {} + for check in checks: + name = check.get("name") + if name not in newest_checks or check.get("id", 0) > newest_checks[name].get( + "id", 0 + ): + newest_checks[name] = check problems = [ - f"check {check.get('name')} is {check.get('conclusion') or check.get('status')}" - for check in checks - if check.get("status") != "completed" - or check.get("conclusion") not in ("success", "skipped", "neutral") + f"workflow {path} ({event}) is {run.get('conclusion') or run.get('status')}" + for (path, event), run in newest_runs.items() + if run.get("status") != "completed" or run.get("conclusion") != "success" ] problems += [ - f"workflow {run.get('path')} is {run.get('conclusion') or run.get('status')}" - for run in runs - if run.get("status") != "completed" or run.get("conclusion") != "success" + f"check {name} is {check.get('conclusion') or check.get('status')}" + for name, check in newest_checks.items() + if check.get("status") != "completed" + or check.get("conclusion") not in ("success", "skipped", "neutral") ] - if not any(run.get("path") == CODE_CHANGES_WORKFLOW for run in runs): - problems.append(f"no {CODE_CHANGES_WORKFLOW} run yet") + if (CODE_CHANGES_WORKFLOW, "workflow_dispatch") not in newest_runs: + problems.append( + f"no {CODE_CHANGES_WORKFLOW} run on the candidate's own tree yet" + ) if not checks: problems.append("no checks reported yet") return sorted(problems) @@ -775,6 +843,11 @@ def parser() -> argparse.ArgumentParser: resolve.add_argument("--out", required=True) resolve.set_defaults(func=cmd_resolve) + still_eligible = commands.add_parser("still-eligible") + still_eligible.add_argument("--candidate", required=True) + still_eligible.add_argument("--ledger", required=True) + still_eligible.set_defaults(func=cmd_still_eligible) + prepare = commands.add_parser("prepare") prepare.add_argument("--candidate", required=True) prepare.add_argument("--out", default="prepared.json") diff --git a/src/policyengine/provenance/uk_qualification.py b/src/policyengine/provenance/uk_qualification.py index dda6f6803..ecf0dfe35 100644 --- a/src/policyengine/provenance/uk_qualification.py +++ b/src/policyengine/provenance/uk_qualification.py @@ -434,15 +434,16 @@ def candidate_action( return "duplicate", ( f"a requalification from base {base_commit[:12]} exists" ) - if _requalification_in_flight( - pair_dir, now=now, owner_is_failed=owner_is_failed - ): + if attempt_in_flight(pair_dir, now=now, owner_is_failed=owner_is_failed): return "duplicate", "a requalification is already in flight" return "qualify", f"promotable but {stale}; requalifying against current main" if receipt is not None and receipt.verdict != "incomplete": return "duplicate", f"pair already {receipt.verdict} in the ledger" if attempt is not None: return "duplicate", f"an attempt from base {base_commit[:12]} exists" + if attempt_in_flight(pair_dir, now=now, owner_is_failed=owner_is_failed): + # Runs resolved on different main commits must not both stage. + return "duplicate", "an attempt for this pair is in flight" return "qualify", "new pair, or an incomplete pair on a new base commit" @@ -450,14 +451,25 @@ def qualified_base(pair_dir: Path) -> Optional[str]: """The main commit prefix the pair's published receipt was built on. An attempt's key is its base commit's first 12 characters, with ``-`` - for a repeat on the same base. Pairs published before - ``latest_attempt.json`` existed have no recorded base. + for a repeat on the same base. Without ``latest_attempt.json`` (pairs + published before it existed), the attempt whose own receipt is the + published one names it. ``None`` when no attempt does. """ pointer = pair_dir / "latest_attempt.json" - if not pointer.exists(): + if pointer.exists(): + return str(read_json(pointer)["attempt_key"]).split("-")[0] + published = pair_dir / "qualification_receipt.json" + if not published.exists(): return None - return str(read_json(pointer)["attempt_key"]).split("-")[0] + digest = QualificationReceipt.model_validate(read_json(published)).unreviewed_sha256 + matching = [ + path.parent.name + for path in sorted(pair_dir.glob("attempts/*/qualification_receipt.json")) + if QualificationReceipt.model_validate(read_json(path)).unreviewed_sha256 + == digest + ] + return matching[-1].split("-")[0] if matching else None def stale_qualification( @@ -476,18 +488,22 @@ def stale_qualification( if receipt.previous_certificate != previous_certificate(bundle): return "qualified against a superseded baseline" base = qualified_base(pair_dir) - if base is not None and base != main_commit[:12]: + if base is None: + return "qualified on an unrecorded main commit" + if base != main_commit[:12]: return f"qualified on main {base}, not the current {main_commit[:12]}" return None -def _requalification_in_flight( +def attempt_in_flight( pair_dir: Path, *, now: Optional[str], owner_is_failed: Optional[Callable[[str], bool]], ) -> bool: - """A staged attempt newer than the published one that is still running.""" + """A staged attempt newer than the published one that is still running: + it has no receipt yet and is not abandoned (a sent replay request is + still running).""" stages = list((pair_dir / "attempts").glob("*/staging.json")) if not stages: diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 8183bf59f..8811be13c 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -308,7 +308,11 @@ def action(base): assert accepted.promotable publish(accepted) assert action("c") == "qualify" - # Against main's own certificate, or not promotable: nothing to do. + # Against main's own certificate, on main's own commit, or not + # promotable: nothing to do. + write_json( + pair_dir / "latest_attempt.json", {"attempt_key": "c" * 12, "sequence": 3} + ) publish(qualified, current) assert action("c") == "duplicate" for results in (gates(metrics_review="needs_review"), gates(wheel_hashes="fail")): @@ -389,6 +393,61 @@ def action(base): assert requalification_target([requalified], BUNDLE, tmp_path, "c" * 40) is None +def test_a_pair_without_an_attempt_pointer_takes_its_base_from_its_attempt(tmp_path): + """Round 2 of #635: a pair published before latest_attempt.json existed + must not be stranded. Its base is the attempt whose own receipt is the + published one; with no such attempt the base is unknown and stale.""" + + from policyengine.provenance.uk_qualification import ( + qualified_base, + requalification_target, + stale_qualification, + ) + + pair = _promotable_pair_and_branch() + pair_dir = tmp_path / "uk" / pair.pair_id + qualified = _promotion_receipt(pair=pair) + write_json(pair_dir / "qualification_receipt.json", qualified.canonical_payload()) + assert qualified_base(pair_dir) is None + assert "unrecorded" in stale_qualification(qualified, BUNDLE, pair_dir, "a" * 40) + assert requalification_target([qualified], BUNDLE, tmp_path, "a" * 40) == pair + _publish_as_attempt(tmp_path, pair, qualified, "a" * 12 + "-2") + assert qualified_base(pair_dir) == "a" * 12 + assert requalification_target([qualified], BUNDLE, tmp_path, "a" * 40) is None + assert requalification_target([qualified], BUNDLE, tmp_path, "b" * 40) == pair + + +def test_runs_resolved_on_different_main_commits_never_both_stage(tmp_path): + """Round 2 of #635: one attempt per pair at a time, whatever its base.""" + + from policyengine.provenance.qualification_ledger import attempt_directory + from policyengine.provenance.uk_qualification import stage_attempt + + pair = make_pair(model="2.200.0") + now = "2026-10-16T10:00:00Z" + assert ( + candidate_action(pair, BUNDLE, tmp_path, base_commit="a" * 40, now=now)[0] + == "qualify" + ) + stage_attempt( + attempt_directory(tmp_path, "uk", pair.pair_id, "a" * 12), + {"pair": pair.canonical_payload(), "pair_id": pair.pair_id}, + HARNESS, + owner_run_id="7", + staged_at="2026-10-16T09:30:00Z", + ) + verdict, reason = candidate_action( + pair, BUNDLE, tmp_path, base_commit="b" * 40, now=now + ) + assert verdict == "duplicate" and "in flight" in reason + # Abandoned (past its expiry), it no longer holds the pair. + later = "2026-10-16T14:00:00Z" + assert ( + candidate_action(pair, BUNDLE, tmp_path, base_commit="b" * 40, now=later)[0] + == "qualify" + ) + + def test_requalify_names_the_target_only_while_its_baseline_is_stale( monkeypatch, tmp_path, capsys ): @@ -410,6 +469,10 @@ def test_requalify_names_the_target_only_while_its_baseline_is_stale( } ) monkeypatch.setattr(cli, "git", lambda *args: "a" * 40) + write_json( + tmp_path / "uk" / current.pair_id / "latest_attempt.json", + {"attempt_key": "a" * 12, "sequence": 1}, + ) for receipts, expected in (([stale], [stale.pair]), ([current], []), ([], [])): monkeypatch.setattr(cli, "latest_receipts", lambda root, r=receipts, **_: r) assert cli.cmd_requalify(argparse.Namespace(ledger=str(tmp_path))) == 0 @@ -861,91 +924,189 @@ def test_a_branch_that_moved_off_the_qualified_commit_is_not_promoted(): assert plan.ready_branch == branch -CHECKS_PASSED = [ - {"name": "Lint", "status": "completed", "conclusion": "success"}, - {"name": "Test (3.13)", "status": "completed", "conclusion": "success"}, - {"name": "Docs", "status": "completed", "conclusion": "skipped"}, -] -RUNS_PASSED = [ - { - "path": ".github/workflows/pr_code_changes.yaml", - "status": "completed", - "conclusion": "success", - } -] +CODE = ".github/workflows/pr_code_changes.yaml" -def _check(name, status="completed", conclusion=None): - return {"name": name, "status": status, "conclusion": conclusion} +def _check(name, status="completed", conclusion="success", id=1): + return {"id": id, "name": name, "status": status, "conclusion": conclusion} +def _run(event, status="completed", conclusion="success", id=1, path=CODE): + return { + "id": id, + "path": path, + "event": event, + "status": status, + "conclusion": conclusion, + } + + +CHECKS_PASSED = [ + _check("Lint", id=1), + _check("Test (3.13)", id=2), + _check("Docs", conclusion="skipped", id=3), +] +RUNS_PASSED = [_run("pull_request", id=10), _run("workflow_dispatch", id=11)] + +# Each: checks, runs, branch at the commit, main ("parent" or "moved"), the +# push's exit code, automatic; then whether it is promoted, and the readiness +# calls made ("ready" or "undo") in order. PROMOTION_CASES = { - "everything passed": (CHECKS_PASSED, RUNS_PASSED, True, 0, True), + "everything passed": ( + CHECKS_PASSED, + RUNS_PASSED, + True, + "parent", + 0, + True, + True, + ["ready"], + ), "a test still running": ( - [*CHECKS_PASSED[:1], _check("Test (3.13)", "in_progress")], + [*CHECKS_PASSED[:1], _check("Test (3.13)", "in_progress", None, id=4)], RUNS_PASSED, True, + "parent", 0, + True, False, + [], ), "a test failed": ( - [*CHECKS_PASSED[:1], _check("Test (3.13)", conclusion="failure")], + [*CHECKS_PASSED[:1], _check("Test (3.13)", conclusion="failure", id=4)], RUNS_PASSED, True, + "parent", 0, + True, False, + [], ), - "a test cancelled": ( - [_check("Test", conclusion="cancelled")], + "an older cancelled run a newer success replaced": ( + [*CHECKS_PASSED, _check("Test (3.13)", conclusion="cancelled", id=0)], + # The API lists newest first. + [*RUNS_PASSED, _run("pull_request", conclusion="cancelled", id=9)], + True, + "parent", + 0, + True, + True, + ["ready"], + ), + "a newer failure after an older success": ( + [*CHECKS_PASSED, _check("Test (3.13)", conclusion="failure", id=9)], RUNS_PASSED, True, + "parent", 0, + True, False, + [], ), - "no checks yet": ([], RUNS_PASSED, True, 0, False), - "only lint so far, the test workflow still registering": ( + "no run on the candidate's own tree": ( + CHECKS_PASSED, + RUNS_PASSED[:1], + True, + "parent", + 0, + True, + False, + [], + ), + "the own-tree run still registering its jobs": ( CHECKS_PASSED[:1], - [{**RUNS_PASSED[0], "status": "in_progress", "conclusion": None}], + [RUNS_PASSED[0], _run("workflow_dispatch", "in_progress", None, id=11)], True, + "parent", 0, + True, False, + [], ), - "the test workflow never ran": ( + "no checks yet": ([], RUNS_PASSED, True, "parent", 0, True, False, []), + "the checks could not be read": ( + None, + RUNS_PASSED, + True, + "parent", + 0, + True, + False, + [], + ), + "the branch moved": ( CHECKS_PASSED, - [ - { - "path": ".github/workflows/pr_docs_changes.yaml", - "status": "completed", - "conclusion": "success", - } - ], + RUNS_PASSED, + False, + "parent", + 0, + True, + False, + ["undo"], + ), + "main moved since the base": ( + CHECKS_PASSED, + RUNS_PASSED, + True, + "moved", + 0, + True, + False, + ["undo"], + ), + "main moved at the push": ( + CHECKS_PASSED, + RUNS_PASSED, + True, + "parent", + 1, + True, + False, + ["ready", "undo"], + ), + "manual promotion, on the base": ( + None, + None, + True, + "parent", + 0, + False, + False, + ["ready"], + ), + "manual promotion, main moved": ( + None, + None, True, + "moved", 0, False, + False, + ["undo"], ), - "the checks could not be read": (None, RUNS_PASSED, True, 0, False), - "the branch moved": (CHECKS_PASSED, RUNS_PASSED, False, 0, False), - "main moved since the base": (CHECKS_PASSED, RUNS_PASSED, True, 1, False), } @pytest.mark.parametrize("case", sorted(PROMOTION_CASES)) -def test_promotion_fast_forwards_main_to_exactly_the_qualified_commit( +def test_promotion_makes_main_exactly_the_qualified_commit( monkeypatch, tmp_path, capsys, case ): - """main is not branch-protected and the repository does not allow - auto-merge. Promotion pushes the qualified commit to main without force, - once every check and pull request workflow run on it has passed: the - push succeeds only while main is still the commit it was built on, so - main becomes exactly the tested tree or nothing changes.""" + """Round 2 of #635. A candidate is readied only while main is still the + commit it was built on, so no merge can combine it with other code. + Automatic promotion waits for the commit's own tree to pass its checks + (the newest run of each workflow, the newest check of each name), then + moves main to it with a push leased on that parent.""" import argparse import importlib.util - checks, runs, branch_at_commit, push_code, promotes = PROMOTION_CASES[case] + checks, runs, at_commit, main, push_code, automatic, promotes, readiness = ( + PROMOTION_CASES[case] + ) qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) branch = candidate_branch(qualified.pair) commit = qualified.harness.git_commit + parent = "p" * 40 spec = importlib.util.spec_from_file_location( "check_uk_candidate_for_test", Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", @@ -956,75 +1117,53 @@ def test_promotion_fast_forwards_main_to_exactly_the_qualified_commit( def run(argv, **kwargs): calls.append(argv) + out = "" + code = 0 if argv[:2] == ["gh", "api"]: items = checks if "check-runs" in argv[2] else runs - if "check-runs" in argv[2]: - assert f"commits/{commit}/" in argv[2] - else: - assert f"head_sha={commit}" in argv[2] if items is None: - return SimpleNamespace(returncode=1, stdout="") - return SimpleNamespace( - returncode=0, stdout="".join(json.dumps(i) + "\n" for i in items) - ) - if argv[:2] == ["git", "rev-parse"]: - head = commit if branch_at_commit else "f" * 40 - return SimpleNamespace(returncode=0, stdout=head + "\n") - if argv[:2] == ["git", "push"]: - return SimpleNamespace(returncode=push_code, stdout="", stderr="") - return SimpleNamespace(returncode=0, stdout="", stderr="") + code = 1 + else: + out = "".join(json.dumps(item) + "\n" for item in items) + elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: + out = commit if at_commit else "f" * 40 + elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: + out = parent + elif argv[:2] == ["git", "ls-remote"]: + out = f"{parent if main == 'parent' else 'm' * 40}\trefs/heads/main\n" + elif argv[:2] == ["git", "push"]: + code = push_code + return SimpleNamespace(returncode=code, stdout=out, stderr="") monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) - monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) + monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": automatic}}) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 - assert calls[0][:3] == ["gh", "pr", "ready"] and calls[0][3] == branch + assert [ + "undo" if "--undo" in argv else "ready" + for argv in calls + if argv[:3] == ["gh", "pr", "ready"] + ] == readiness pushes = [argv for argv in calls if argv[:2] == ["git", "push"]] - assert all("--force" not in argv and "-f" not in argv for argv in pushes) - assert not [argv for argv in calls if argv[:3] == ["gh", "pr", "merge"]] - reached_push = ( - checks is not None and promotes or case == "main moved since the base" - ) - if reached_push: - assert pushes == [["git", "push", "origin", f"{commit}:refs/heads/main"]] - else: - assert pushes == [] - out = capsys.readouterr().out - assert (f"promoted {branch}" in out) is promotes - if case == "main moved since the base": - assert "requalified on current main" in out - - -def test_without_automatic_promotion_the_target_is_only_readied(monkeypatch, tmp_path): - import argparse - import importlib.util - - qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) - branch = candidate_branch(qualified.pair) - spec = importlib.util.spec_from_file_location( - "check_uk_candidate_manual", - Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", - ) - cli = importlib.util.module_from_spec(spec) - spec.loader.exec_module(cli) - calls = [] - monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) - monkeypatch.setattr( - cli, - "subprocess", - SimpleNamespace( - run=lambda argv, **k: ( - calls.append(argv) or SimpleNamespace(returncode=0, stdout="") - ) - ), - ) - monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": False}}) - monkeypatch.setattr( - cli, "_open_candidate_branches", lambda: {branch: qualified.harness.git_commit} + reaches_push = "ready" in readiness and automatic + assert pushes == ( + [ + [ + "git", + "push", + f"--force-with-lease=refs/heads/main:{parent}", + "origin", + f"{commit}:refs/heads/main", + ] + ] + if reaches_push + else [] ) - assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 - assert [argv[:3] for argv in calls] == [["gh", "pr", "ready"]] + assert not [argv for argv in calls if argv[:3] == ["gh", "pr", "merge"]] + if not automatic: + assert not [argv for argv in calls if argv[:2] == ["gh", "api"]] + assert (f"promoted {branch}" in capsys.readouterr().out) is promotes def test_automatic_promotion_is_on_with_its_basis_recorded(): diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 5a59769bf..2bf4e7f56 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -19,7 +19,7 @@ candidate_branch, previous_certificate, ) -from tests.qualification_fixtures import gates, make_pair, make_receipt +from tests.qualification_fixtures import HARNESS, gates, make_pair, make_receipt REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW_PATH = REPO_ROOT / ".github/workflows/certify-uk-release.yaml" @@ -146,6 +146,68 @@ def test_every_dispatch_names_the_exact_pair(): assert 'entry["pair_id"]' in run +def test_preparation_rechecks_eligibility_and_tests_the_candidates_own_tree(): + """Round 2 of #635: under the preparation lock, a run whose pair another + run is already qualifying (from any main commit) stops; and the + code-changes workflow runs on the candidate branch itself, the tree + promotion would put on main.""" + + job = _workflow()["jobs"]["prepare"] + steps = job["steps"] + names = [step.get("name") for step in steps] + duplicate = steps[names.index("Check for an attempt staged by another run")] + assert "check_uk_candidate.py still-eligible" in duplicate["run"] + assert names.index("Install the base environment") < names.index( + "Check for an attempt staged by another run" + ) + own_tree = steps[names.index("Test the candidate commit's own tree")] + assert own_tree["run"].startswith("gh workflow run pr_code_changes.yaml") + assert '--ref "$BRANCH"' in own_tree["run"] + assert own_tree["env"]["GH_TOKEN"] == "${{ github.token }}" + assert names.index("Commit and push the candidate commit") < names.index( + "Test the candidate commit's own tree" + ) + assert job["permissions"]["actions"] == "write" + triggers = yaml.safe_load( + (REPO_ROOT / ".github/workflows/pr_code_changes.yaml").read_text() + )[True] + assert "workflow_dispatch" in triggers + + +def test_still_eligible_reports_a_pair_another_run_is_qualifying(monkeypatch, tmp_path): + from policyengine.provenance.qualification_ledger import attempt_directory + from policyengine.provenance.uk_qualification import stage_attempt + + cli = _cli() + pair = make_pair(model="2.200.0") + candidate = tmp_path / "candidate.json" + candidate.write_text( + json.dumps( + { + "pair": pair.canonical_payload(), + "pair_id": pair.pair_id, + "base_commit": "b" * 40, + "force": False, + } + ) + ) + outputs = [] + monkeypatch.setattr(cli, "set_output", lambda **kwargs: outputs.append(kwargs)) + monkeypatch.setattr(cli, "utc_now", lambda: "2026-10-16T10:00:00Z") + args = argparse.Namespace(candidate=str(candidate), ledger=str(tmp_path)) + assert cli.cmd_still_eligible(args) == 0 + assert outputs[-1] == {"duplicate": "false"} + stage_attempt( + attempt_directory(tmp_path, "uk", pair.pair_id, "a" * 12), + {"pair": pair.canonical_payload(), "pair_id": pair.pair_id}, + HARNESS, + owner_run_id="7", + staged_at="2026-10-16T09:30:00Z", + ) + assert cli.cmd_still_eligible(args) == 0 + assert outputs[-1] == {"duplicate": "true"} + + def test_preparation_syncs_the_candidate_lock_before_using_it(): """Snapshots, the bundle check and staging must run in the candidate's environment: synced from the lock preparation regenerates, after it is @@ -270,7 +332,19 @@ def fake_run(argv, **kwargs): [{"headRefName": branch, "headRefOid": state["head"]}] ) ) + # Promotion reads the branch head and checks main is its parent. + if argv[:2] == ["git", "fetch"]: + return SimpleNamespace(returncode=0, stdout="") + if argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: + head = json.loads(state_path.read_text())["head"] + return SimpleNamespace(returncode=0, stdout=head) + if argv[:2] == ["git", "rev-parse"]: + return SimpleNamespace(returncode=0, stdout="p" * 40) + if argv[:2] == ["git", "ls-remote"]: + return SimpleNamespace(returncode=0, stdout="p" * 40 + "\trefs/heads/main") assert argv[:3] == ["gh", "pr", "ready"] + if "--undo" in argv: + return SimpleNamespace(returncode=0) if not prepare_first: schedule("prepare", prepare) state = json.loads(state_path.read_text()) @@ -435,6 +509,8 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( (14, "a" * 40, "pull_request", "completed", "failure"), (15, head, "push", "completed", "failure"), (16, head, "pull_request", "completed", "timed_out"), + # The run on the candidate's own tree that promotion requires. + (17, head, "workflow_dispatch", "completed", "failure"), ) ] calls = [] @@ -444,7 +520,6 @@ def fake_run(argv, **kwargs): if argv[:2] == ["gh", "api"]: assert "--paginate" in argv assert f"head_sha={receipt.harness.git_commit}" in argv[2] - assert "event=pull_request" in argv[2] return SimpleNamespace(stdout="\n".join(json.dumps(run) for run in runs)) assert argv[:3] == ["gh", "run", "rerun"] assert "--failed" in argv and kwargs["check"] is True @@ -455,6 +530,6 @@ def fake_run(argv, **kwargs): assert args.func(args) == 0 reruns = [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] if receipt.promotable and head_matches: - assert reruns == ["11", "16"] + assert reruns == ["11", "16", "17"] else: assert calls == [] From 113330ca9e0d30434320e8f8e9b61c981dc12f92 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 01:14:39 -0400 Subject: [PATCH 07/18] Replace the 1.58.0 metrics fixture with W3's suppressed 1.58.1 report The 1.58.0 report predates the tool's small-cell rule and published Disabled Students' Allowance's total and caseload, which come from fewer than 10 records, unsuppressed; totals containing it gave it back. The 1.58.1 report at tool commit 228a05a3e publishes those figures as suppressed and rebuilds every total without them. The gate and tests that read the report move to the suppression format in the next commit. History is left as is pending a security decision. Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/uk_metrics/README.md | 35 +- .../uk_metrics/budget_metrics_report.json | 5692 +++++++++++++---- 2 files changed, 4538 insertions(+), 1189 deletions(-) diff --git a/tests/fixtures/uk_metrics/README.md b/tests/fixtures/uk_metrics/README.md index f438a64c7..3a26ba332 100644 --- a/tests/fixtures/uk_metrics/README.md +++ b/tests/fixtures/uk_metrics/README.md @@ -1,24 +1,27 @@ # UK metrics tool fixtures Both files come from PolicyEngine/policyengine-uk's aggregate-impact tool -(`policyengine_uk/utils/aggregate_impact/`, policyengine-uk#2335), which the -qualification config pins at commit -`9b33fe19f80676fc5b81d2d80b36072e1c2fc1df`. They were produced at its parent, -`bd8bff74c0baea19c09eedf9ce1391d598d2ffaf`; the pinned commit changes only -when the tool digest is taken (at module load rather than at the end of a -run), so `standard_reforms.yaml` is byte-identical there and the report format -is unchanged. A report from the pinned commit differs only in -`metrics_version`, which the gate compares between the two pairs' reports, -never against this file. `tests/test_uk_candidate_gates.py` reads them. -Refresh them when a pin changes the report; never edit them by hand. +(`policyengine_uk/utils/aggregate_impact/`, policyengine-uk#2335). +`tests/test_uk_candidate_gates.py` reads them. Refresh them when a pin changes +the report; never edit them by hand. +- `budget_metrics_report.json`: a real `compute_budget_metrics` report from + the tool's owner (workstream W3), at tool commit + `228a05a3e925beb50da619aa01e68c266e578fc0`, on the certified pair: uk-data + 1.58.1 (`enhanced_frs_2024_25`) with policyengine-uk 2.130.1 and + policyengine-core 3.33.0, for 2025-26 to 2031-32, with the overlap guard and + the ten standard reforms. It holds aggregates only, under the tool's + small-cell rule (`privacy.py`): a figure from fewer than 10 records is + published as `value` null with `suppressed` true, and every total that + holds one is rebuilt without it and lists it under `excludes`. It has + 1,484 metric rows (14 suppressed: Disabled Students' Allowance's total and + caseload in each of the seven years) and 350 checks. - `standard_reforms.yaml`: the tool's standard reform set, copied unchanged. Ten reforms, each with the model variable it must move, the direction and the least it must move it in every year. The reform responsiveness gate reads the same file from the vendored tool. -- `budget_metrics_report.json`: a real `compute_budget_metrics` report from - the tool's owner (workstream W3), run on 11 October 2026 at the pinned tool - commit on uk-data 1.58.0 (`enhanced_frs_2024_25`) with policyengine-uk main, - for 2025-26 to 2031-32, with the overlap guard and the ten standard reforms. - It holds aggregates only: 1,470 metric rows and 346 checks (327 pass, - 15 warn, 4 fail), each check with its `failing_scopes`. + +This report replaces a 1.58.0 report from before the tool suppressed small +cells. That report published Disabled Students' Allowance, which comes from +fewer than 10 records, unsuppressed. Never commit a report from a tool +commit that predates suppression. diff --git a/tests/fixtures/uk_metrics/budget_metrics_report.json b/tests/fixtures/uk_metrics/budget_metrics_report.json index 6b599fb5d..76fc07331 100644 --- a/tests/fixtures/uk_metrics/budget_metrics_report.json +++ b/tests/fixtures/uk_metrics/budget_metrics_report.json @@ -1,8 +1,8 @@ { "schema": "policyengine-uk-aggregate-impact/1/report", "status": "ok", - "metrics_version": "policyengine-uk-aggregate-impact/1+3d0c53d5b453bd14+bf81c943e4ebec5a", - "partition_version": "779be6fce5878c1a358e1d921e4427cae51b2cb9391dabf923b5c624ae2d1070", + "metrics_version": "policyengine-uk-aggregate-impact/1+47b285096857148a+bb5198a0d8e7c046", + "partition_version": "e8ea8a0198736e7cd9b0725148fdda717911033ee768eba044b58a8d46e9b0ef", "years": [ 2025, 2026, @@ -16,7 +16,9 @@ { "metric_id": "population.person", "year": 2025, - "value": 69712844.3129216, + "value": 69513955.15348345, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -25,7 +27,9 @@ { "metric_id": "population.person", "year": 2026, - "value": 69977755.64010108, + "value": 69778110.5666471, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -34,7 +38,9 @@ { "metric_id": "population.person", "year": 2027, - "value": 70236674.50701487, + "value": 70036290.77594277, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -43,7 +49,9 @@ { "metric_id": "population.person", "year": 2028, - "value": 70517617.61980712, + "value": 70316432.3587328, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -52,7 +60,9 @@ { "metric_id": "population.person", "year": 2029, - "value": 70827896.20421527, + "value": 70625825.64596106, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -61,7 +71,9 @@ { "metric_id": "population.person", "year": 2030, - "value": 71146624.04218265, + "value": 70943644.05542476, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -70,7 +82,9 @@ { "metric_id": "population.person", "year": 2031, - "value": 71452690.09232165, + "value": 71248701.50975493, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -79,7 +93,9 @@ { "metric_id": "population.benunit", "year": 2025, - "value": 35436904.67127848, + "value": 35246926.333675, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -88,7 +104,9 @@ { "metric_id": "population.benunit", "year": 2026, - "value": 35571566.183018014, + "value": 35380865.871866405, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -97,7 +115,9 @@ { "metric_id": "population.benunit", "year": 2027, - "value": 35703181.570698276, + "value": 35511775.69777489, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -106,7 +126,9 @@ { "metric_id": "population.benunit", "year": 2028, - "value": 35845992.467239186, + "value": 35653820.972568765, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -115,7 +137,9 @@ { "metric_id": "population.benunit", "year": 2029, - "value": 36003715.37718047, + "value": 35810698.281318754, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -124,7 +148,9 @@ { "metric_id": "population.benunit", "year": 2030, - "value": 36165733.258539036, + "value": 35971847.531772316, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -133,7 +159,9 @@ { "metric_id": "population.benunit", "year": 2031, - "value": 36321314.823288456, + "value": 36126526.36007868, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -142,7 +170,9 @@ { "metric_id": "population.household", "year": 2025, - "value": 31245113.389789984, + "value": 31166694.04532416, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -151,7 +181,9 @@ { "metric_id": "population.household", "year": 2026, - "value": 31363845.951987505, + "value": 31285128.558958113, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -160,7 +192,9 @@ { "metric_id": "population.household", "year": 2027, - "value": 31479892.706091657, + "value": 31400884.081961572, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -169,7 +203,9 @@ { "metric_id": "population.household", "year": 2028, - "value": 31605810.66237679, + "value": 31526486.005968377, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -178,7 +214,9 @@ { "metric_id": "population.household", "year": 2029, - "value": 31744876.701983795, + "value": 31665202.984048083, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -187,7 +225,9 @@ { "metric_id": "population.household", "year": 2030, - "value": 31887729.66803205, + "value": 31807697.37639664, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -196,7 +236,9 @@ { "metric_id": "population.household", "year": 2031, - "value": 32024907.664337814, + "value": 31944470.379216716, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "population", "partition": null, @@ -205,7 +247,9 @@ { "metric_id": "receipts.income_tax", "year": 2025, - "value": 300493514624.05597, + "value": 297984459607.39465, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -214,7 +258,9 @@ { "metric_id": "receipts.income_tax", "year": 2026, - "value": 320544520884.17847, + "value": 317782510223.38354, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -223,7 +269,9 @@ { "metric_id": "receipts.income_tax", "year": 2027, - "value": 338021251015.9257, + "value": 335140035823.943, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -232,7 +280,9 @@ { "metric_id": "receipts.income_tax", "year": 2028, - "value": 353227552195.2491, + "value": 350031424626.733, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -241,7 +291,9 @@ { "metric_id": "receipts.income_tax", "year": 2029, - "value": 369117767213.9463, + "value": 366458443148.21484, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -250,7 +302,9 @@ { "metric_id": "receipts.income_tax", "year": 2030, - "value": 387232407347.2469, + "value": 384662297800.91766, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -259,7 +313,9 @@ { "metric_id": "receipts.income_tax", "year": 2031, - "value": 411429752919.1772, + "value": 403275325039.3891, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "income_tax", @@ -268,7 +324,9 @@ { "metric_id": "receipts.nics_individual", "year": 2025, - "value": 53131882407.61014, + "value": 52575017190.03174, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -277,7 +335,9 @@ { "metric_id": "receipts.nics_individual", "year": 2026, - "value": 55499684495.07545, + "value": 54941380589.82846, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -286,7 +346,9 @@ { "metric_id": "receipts.nics_individual", "year": 2027, - "value": 57316093047.9056, + "value": 56752394914.42203, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -295,7 +357,9 @@ { "metric_id": "receipts.nics_individual", "year": 2028, - "value": 59021168416.7411, + "value": 58476221815.846825, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -304,7 +368,9 @@ { "metric_id": "receipts.nics_individual", "year": 2029, - "value": 60933744650.36868, + "value": 60511486452.76118, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -313,7 +379,9 @@ { "metric_id": "receipts.nics_individual", "year": 2030, - "value": 62865473690.570404, + "value": 62445204124.271996, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -322,7 +390,9 @@ { "metric_id": "receipts.nics_individual", "year": 2031, - "value": 65321678070.619606, + "value": 64898705801.9577, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_individual", @@ -331,7 +401,9 @@ { "metric_id": "receipts.nics_employer", "year": 2025, - "value": 154541037552.12073, + "value": 152640935215.8076, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -340,7 +412,9 @@ { "metric_id": "receipts.nics_employer", "year": 2026, - "value": 161154088343.2087, + "value": 159182492481.57574, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -349,7 +423,9 @@ { "metric_id": "receipts.nics_employer", "year": 2027, - "value": 166167835008.01767, + "value": 164141628899.95114, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -358,7 +434,9 @@ { "metric_id": "receipts.nics_employer", "year": 2028, - "value": 170808399523.67877, + "value": 168731462725.2504, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -367,7 +445,9 @@ { "metric_id": "receipts.nics_employer", "year": 2029, - "value": 177113429874.5147, + "value": 175566267043.68347, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -376,7 +456,9 @@ { "metric_id": "receipts.nics_employer", "year": 2030, - "value": 182684601617.84186, + "value": 181079291564.9595, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -385,7 +467,9 @@ { "metric_id": "receipts.nics_employer", "year": 2031, - "value": 189883460588.0551, + "value": 188223742975.49728, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "nics_employer", @@ -395,6 +479,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -404,6 +490,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -413,6 +501,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -422,6 +512,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -431,6 +523,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -440,6 +534,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -449,6 +545,8 @@ "metric_id": "receipts.employer_nics_response_capital", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_capital", @@ -458,6 +556,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -467,6 +567,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -476,6 +578,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -485,6 +589,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -494,6 +600,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -503,6 +611,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -512,6 +622,8 @@ "metric_id": "receipts.employer_nics_response_consumer", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "employer_nics_response_consumer", @@ -520,7 +632,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2025, - "value": 12717206634.11718, + "value": 12618588087.194477, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -529,7 +643,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2026, - "value": 13157373776.93752, + "value": 13055343054.272093, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -538,7 +654,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2027, - "value": 13652775596.971504, + "value": 13547024733.440294, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -547,7 +665,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2028, - "value": 14151592611.504883, + "value": 14041998268.40096, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -556,7 +676,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2029, - "value": 14653801672.085384, + "value": 14540347118.814587, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -565,7 +687,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2030, - "value": 15204600241.922672, + "value": 15086908668.221151, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -574,7 +698,9 @@ { "metric_id": "receipts.capital_gains_tax", "year": 2031, - "value": 15768244652.787172, + "value": 15645569134.728756, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "capital_gains_tax", @@ -583,7 +709,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2025, - "value": 7830274125.077207, + "value": 7826615852.272217, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -592,7 +720,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2026, - "value": 8127270450.824548, + "value": 8123473394.454311, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -601,7 +731,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2027, - "value": 8353117705.791328, + "value": 8349215176.222883, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -610,7 +742,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2028, - "value": 8562646845.026996, + "value": 8558646443.275475, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -619,7 +753,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2029, - "value": 8789529758.204428, + "value": 8785423316.325344, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -628,7 +764,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2030, - "value": 9040980903.523558, + "value": 9036756985.870995, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -637,7 +775,9 @@ { "metric_id": "receipts.student_loan_repayments", "year": 2031, - "value": 8059998166.851627, + "value": 9376925443.173416, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "student_loan_repayments", @@ -646,7 +786,9 @@ { "metric_id": "receipts.vat", "year": 2025, - "value": 317136171663.45935, + "value": 313127412006.5256, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -655,7 +797,9 @@ { "metric_id": "receipts.vat", "year": 2026, - "value": 325663150441.0445, + "value": 321546605195.24274, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -664,7 +808,9 @@ { "metric_id": "receipts.vat", "year": 2027, - "value": 333405472036.0278, + "value": 329191061310.5304, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -673,7 +819,9 @@ { "metric_id": "receipts.vat", "year": 2028, - "value": 341433856818.8758, + "value": 337117963034.16547, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -682,7 +830,9 @@ { "metric_id": "receipts.vat", "year": 2029, - "value": 349794893731.6442, + "value": 345373312536.52356, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -691,7 +841,9 @@ { "metric_id": "receipts.vat", "year": 2030, - "value": 358396364144.5514, + "value": 353866055269.635, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -700,7 +852,9 @@ { "metric_id": "receipts.vat", "year": 2031, - "value": 367137164287.62054, + "value": 362495430544.1016, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat", @@ -710,6 +864,8 @@ "metric_id": "receipts.vat_change", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -719,6 +875,8 @@ "metric_id": "receipts.vat_change", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -728,6 +886,8 @@ "metric_id": "receipts.vat_change", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -737,6 +897,8 @@ "metric_id": "receipts.vat_change", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -746,6 +908,8 @@ "metric_id": "receipts.vat_change", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -755,6 +919,8 @@ "metric_id": "receipts.vat_change", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -764,6 +930,8 @@ "metric_id": "receipts.vat_change", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vat_change", @@ -773,6 +941,8 @@ "metric_id": "receipts.private_school_vat", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -782,6 +952,8 @@ "metric_id": "receipts.private_school_vat", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -791,6 +963,8 @@ "metric_id": "receipts.private_school_vat", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -800,6 +974,8 @@ "metric_id": "receipts.private_school_vat", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -809,6 +985,8 @@ "metric_id": "receipts.private_school_vat", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -818,6 +996,8 @@ "metric_id": "receipts.private_school_vat", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -827,6 +1007,8 @@ "metric_id": "receipts.private_school_vat", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "private_school_vat", @@ -835,7 +1017,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2025, - "value": 24041873879.224743, + "value": 24534379561.8306, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -844,7 +1028,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2026, - "value": 24144661501.69051, + "value": 24639271891.62144, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -853,7 +1039,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2027, - "value": 26103457396.62795, + "value": 26638194760.36437, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -862,7 +1050,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2028, - "value": 26158807847.977238, + "value": 26694679049.418755, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -871,7 +1061,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2029, - "value": 25887798204.586216, + "value": 26418117577.300167, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -880,7 +1072,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2030, - "value": 25243234770.62697, + "value": 25760349910.6802, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -889,7 +1083,9 @@ { "metric_id": "receipts.fuel_duty", "year": 2031, - "value": 25824982812.576405, + "value": 26353950047.77822, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "fuel_duty", @@ -899,6 +1095,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -908,6 +1106,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -917,6 +1117,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -926,6 +1128,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -935,6 +1139,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -944,6 +1150,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -953,6 +1161,8 @@ "metric_id": "receipts.alcohol_duty", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "alcohol_duty", @@ -962,6 +1172,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -971,6 +1183,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -980,6 +1194,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -989,6 +1205,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -998,6 +1216,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -1007,6 +1227,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -1016,6 +1238,8 @@ "metric_id": "receipts.tobacco_duty", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tobacco_duty", @@ -1025,6 +1249,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1034,6 +1260,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1043,6 +1271,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1052,6 +1282,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1061,6 +1293,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1070,6 +1304,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1079,6 +1315,8 @@ "metric_id": "receipts.vehicle_excise_duty", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "vehicle_excise_duty", @@ -1087,7 +1325,9 @@ { "metric_id": "receipts.tv_licence", "year": 2025, - "value": 4306586174.401852, + "value": 4275969952.7872777, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1096,7 +1336,9 @@ { "metric_id": "receipts.tv_licence", "year": 2026, - "value": 4459282492.68721, + "value": 4427595383.179631, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1105,7 +1347,9 @@ { "metric_id": "receipts.tv_licence", "year": 2027, - "value": 4476138521.654851, + "value": 4444328769.554615, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1114,7 +1358,9 @@ { "metric_id": "receipts.tv_licence", "year": 2028, - "value": 4496883532.289182, + "value": 4464697036.417519, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1123,7 +1369,9 @@ { "metric_id": "receipts.tv_licence", "year": 2029, - "value": 4517653126.548064, + "value": 4485128769.03225, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1132,7 +1380,9 @@ { "metric_id": "receipts.tv_licence", "year": 2030, - "value": 4538717337.874185, + "value": 4506096907.015281, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1141,7 +1391,9 @@ { "metric_id": "receipts.tv_licence", "year": 2031, - "value": 4613696461.117936, + "value": 4526586455.751576, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "tv_licence", @@ -1150,7 +1402,9 @@ { "metric_id": "receipts.council_tax", "year": 2025, - "value": 49771708157.89493, + "value": 49560513009.82745, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1159,7 +1413,9 @@ { "metric_id": "receipts.council_tax", "year": 2026, - "value": 52672603769.35538, + "value": 52449635526.310104, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1168,7 +1424,9 @@ { "metric_id": "receipts.council_tax", "year": 2027, - "value": 56014381640.763115, + "value": 55778039679.1455, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1177,7 +1435,9 @@ { "metric_id": "receipts.council_tax", "year": 2028, - "value": 59637347359.69022, + "value": 59386561832.62195, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1186,7 +1446,9 @@ { "metric_id": "receipts.council_tax", "year": 2029, - "value": 63246644418.46654, + "value": 62981354541.126625, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1195,7 +1457,9 @@ { "metric_id": "receipts.council_tax", "year": 2030, - "value": 67017263920.12569, + "value": 66736811407.45229, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1204,7 +1468,9 @@ { "metric_id": "receipts.council_tax", "year": 2031, - "value": 67305565868.62192, + "value": 70704732540.45876, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "council_tax", @@ -1214,6 +1480,8 @@ "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1223,6 +1491,8 @@ "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1232,6 +1502,8 @@ "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1240,7 +1512,9 @@ { "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2028, - "value": 493860763.3252144, + "value": 483900104.6233177, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1249,7 +1523,9 @@ { "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2029, - "value": 505953941.79338336, + "value": 495749379.82161856, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1258,7 +1534,9 @@ { "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2030, - "value": 518394260.7963829, + "value": 507938784.7696847, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1267,7 +1545,9 @@ { "metric_id": "receipts.high_value_council_tax_surcharge", "year": 2031, - "value": 531037020.5527634, + "value": 520325557.6779943, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "high_value_council_tax_surcharge", @@ -1276,7 +1556,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2025, - "value": 584068659.0970445, + "value": 552492559.6753057, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1285,7 +1567,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2026, - "value": 611674416.437277, + "value": 578605888.7461181, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1294,7 +1578,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2027, - "value": 640521131.9170634, + "value": 605893088.8719264, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1303,7 +1589,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2028, - "value": 670928666.9686946, + "value": 634656724.1676605, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1312,7 +1600,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2029, - "value": 703059797.6875113, + "value": 665050775.6261593, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1321,7 +1611,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2030, - "value": 736803086.5428002, + "value": 696969823.2250919, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1330,7 +1622,9 @@ { "metric_id": "receipts.domestic_rates", "year": 2031, - "value": 739972742.2779194, + "value": 730275344.6123755, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "domestic_rates", @@ -1339,7 +1633,9 @@ { "metric_id": "receipts.business_rates", "year": 2025, - "value": 31732500424.943848, + "value": 31732501414.791157, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1348,7 +1644,9 @@ { "metric_id": "receipts.business_rates", "year": 2026, - "value": 31732499121.74478, + "value": 31732500125.540802, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1357,7 +1655,9 @@ { "metric_id": "receipts.business_rates", "year": 2027, - "value": 31732496311.19231, + "value": 31732498126.72371, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1366,7 +1666,9 @@ { "metric_id": "receipts.business_rates", "year": 2028, - "value": 31732499335.602257, + "value": 31732499316.105175, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1375,7 +1677,9 @@ { "metric_id": "receipts.business_rates", "year": 2029, - "value": 31732495775.940987, + "value": 31732494964.235413, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1384,7 +1688,9 @@ { "metric_id": "receipts.business_rates", "year": 2030, - "value": 31732497033.93507, + "value": 31732498638.635994, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1393,7 +1699,9 @@ { "metric_id": "receipts.business_rates", "year": 2031, - "value": 31732498138.642197, + "value": 31732494471.035408, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "business_rates", @@ -1402,7 +1710,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2025, - "value": 19447521756.60174, + "value": 19073759714.657246, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1411,7 +1721,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2026, - "value": 20439906422.593903, + "value": 20077204232.549347, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1420,7 +1732,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2027, - "value": 21414316294.15224, + "value": 21035871774.44113, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1429,7 +1743,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2028, - "value": 22393098228.154316, + "value": 21999031263.396732, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1438,7 +1754,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2029, - "value": 23394928012.007107, + "value": 22985619104.754326, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1447,7 +1765,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2030, - "value": 24493314488.64765, + "value": 24063362268.549835, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1456,7 +1776,9 @@ { "metric_id": "receipts.sdlt_household", "year": 2031, - "value": 25645659350.923958, + "value": 25218986028.960094, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "sdlt_household", @@ -1465,7 +1787,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2025, - "value": 2926955.7028617123, + "value": 3441794.3282832364, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1474,7 +1798,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2026, - "value": 3159363.4106524643, + "value": 3718451.781090757, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1483,7 +1809,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2027, - "value": 3426934.00067959, + "value": 4036378.263004425, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1492,7 +1820,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2028, - "value": 3700930.558409606, + "value": 4360394.001039479, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1501,7 +1831,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2029, - "value": 3976395.985409789, + "value": 4685725.303715903, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1510,7 +1842,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2030, - "value": 4283730.381307287, + "value": 5044918.447127742, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1519,7 +1853,9 @@ { "metric_id": "receipts.land_transaction_tax", "year": 2031, - "value": 4617409.568686154, + "value": 5422944.119211734, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_transaction_tax", @@ -1528,7 +1864,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2025, - "value": 99491389.10783455, + "value": 109691161.84922537, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1537,7 +1875,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2026, - "value": 103957133.89622791, + "value": 114611758.83902286, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1546,7 +1886,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2027, - "value": 109071438.72328326, + "value": 120242919.16397013, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1555,7 +1897,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2028, - "value": 114234820.44540606, + "value": 125932263.95004636, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1564,7 +1908,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2029, - "value": 119415529.40185314, + "value": 131640517.58306551, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1573,7 +1919,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2030, - "value": 125108787.1325237, + "value": 137912689.4621755, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1582,7 +1930,9 @@ { "metric_id": "receipts.land_and_buildings_transaction_tax", "year": 2031, - "value": 130998504.05282259, + "value": 144400087.45167437, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_and_buildings_transaction_tax", @@ -1592,6 +1942,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1601,6 +1953,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1610,6 +1964,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1619,6 +1975,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1628,6 +1986,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1637,6 +1997,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1646,6 +2008,8 @@ "metric_id": "receipts.corporate_incident_tax_change", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "corporate_incident_tax_change", @@ -1655,6 +2019,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1664,6 +2030,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1673,6 +2041,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1682,6 +2052,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1691,6 +2063,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1700,6 +2074,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1709,6 +2085,8 @@ "metric_id": "receipts.consumer_incident_tax_change", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "consumer_incident_tax_change", @@ -1718,6 +2096,8 @@ "metric_id": "receipts.wealth_tax", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1727,6 +2107,8 @@ "metric_id": "receipts.wealth_tax", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1736,6 +2118,8 @@ "metric_id": "receipts.wealth_tax", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1745,6 +2129,8 @@ "metric_id": "receipts.wealth_tax", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1754,6 +2140,8 @@ "metric_id": "receipts.wealth_tax", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1763,6 +2151,8 @@ "metric_id": "receipts.wealth_tax", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1772,6 +2162,8 @@ "metric_id": "receipts.wealth_tax", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "wealth_tax", @@ -1781,6 +2173,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1790,6 +2184,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1799,6 +2195,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1808,6 +2206,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1817,6 +2217,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1826,6 +2228,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1835,6 +2239,8 @@ "metric_id": "receipts.non_primary_residence_wealth_tax", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "non_primary_residence_wealth_tax", @@ -1844,6 +2250,8 @@ "metric_id": "receipts.land_value_tax", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1853,6 +2261,8 @@ "metric_id": "receipts.land_value_tax", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1862,6 +2272,8 @@ "metric_id": "receipts.land_value_tax", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1871,6 +2283,8 @@ "metric_id": "receipts.land_value_tax", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1880,6 +2294,8 @@ "metric_id": "receipts.land_value_tax", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1889,6 +2305,8 @@ "metric_id": "receipts.land_value_tax", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1898,6 +2316,8 @@ "metric_id": "receipts.land_value_tax", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "land_value_tax", @@ -1907,6 +2327,8 @@ "metric_id": "receipts.carbon_tax", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1916,6 +2338,8 @@ "metric_id": "receipts.carbon_tax", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1925,6 +2349,8 @@ "metric_id": "receipts.carbon_tax", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1934,6 +2360,8 @@ "metric_id": "receipts.carbon_tax", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1943,6 +2371,8 @@ "metric_id": "receipts.carbon_tax", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1952,6 +2382,8 @@ "metric_id": "receipts.carbon_tax", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1961,6 +2393,8 @@ "metric_id": "receipts.carbon_tax", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "receipt", "partition": "carbon_tax", @@ -1969,7 +2403,9 @@ { "metric_id": "spending.state_pension", "year": 2025, - "value": 123608843187.93976, + "value": 122503036176.28136, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -1978,7 +2414,9 @@ { "metric_id": "spending.state_pension", "year": 2026, - "value": 128313805027.58318, + "value": 127115003712.11774, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -1987,7 +2425,9 @@ { "metric_id": "spending.state_pension", "year": 2027, - "value": 130127006024.04692, + "value": 128280611587.4219, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -1996,7 +2436,9 @@ { "metric_id": "spending.state_pension", "year": 2028, - "value": 132238843074.43095, + "value": 130209285651.49004, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -2005,7 +2447,9 @@ { "metric_id": "spending.state_pension", "year": 2029, - "value": 136141146063.11377, + "value": 134051705144.42847, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -2014,7 +2458,9 @@ { "metric_id": "spending.state_pension", "year": 2030, - "value": 140172899465.68817, + "value": 138021583333.92395, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -2023,7 +2469,9 @@ { "metric_id": "spending.state_pension", "year": 2031, - "value": 144295261485.49826, + "value": 142080411583.76672, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "state_pension", @@ -2032,7 +2480,9 @@ { "metric_id": "caseloads.state_pension", "year": 2025, - "value": 11410127.781640545, + "value": 11307942.897278756, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2041,7 +2491,9 @@ { "metric_id": "caseloads.state_pension", "year": 2026, - "value": 11314830.782796293, + "value": 11208358.708708271, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2050,7 +2502,9 @@ { "metric_id": "caseloads.state_pension", "year": 2027, - "value": 11062132.340065777, + "value": 10906221.550106525, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2059,7 +2513,9 @@ { "metric_id": "caseloads.state_pension", "year": 2028, - "value": 10978110.546501547, + "value": 10811938.301638946, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2068,7 +2524,9 @@ { "metric_id": "caseloads.state_pension", "year": 2029, - "value": 11026414.415012136, + "value": 10859510.9701875, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2077,7 +2535,9 @@ { "metric_id": "caseloads.state_pension", "year": 2030, - "value": 11076033.608679011, + "value": 10908379.084595531, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2086,7 +2546,9 @@ { "metric_id": "caseloads.state_pension", "year": 2031, - "value": 11123681.665928766, + "value": 10955285.089603484, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "state_pension", @@ -2095,7 +2557,9 @@ { "metric_id": "spending.pension_credit", "year": 2025, - "value": 6693405148.200283, + "value": 6683600257.719555, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2104,7 +2568,9 @@ { "metric_id": "spending.pension_credit", "year": 2026, - "value": 7053483709.162087, + "value": 7056299099.336407, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2113,7 +2579,9 @@ { "metric_id": "spending.pension_credit", "year": 2027, - "value": 7033310841.277825, + "value": 7040775597.466119, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2122,7 +2590,9 @@ { "metric_id": "spending.pension_credit", "year": 2028, - "value": 7106016715.233224, + "value": 7110102297.589521, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2131,7 +2601,9 @@ { "metric_id": "spending.pension_credit", "year": 2029, - "value": 7226848094.900362, + "value": 7231026514.317166, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2140,7 +2612,9 @@ { "metric_id": "spending.pension_credit", "year": 2030, - "value": 7357686467.835372, + "value": 7365763489.682707, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2149,7 +2623,9 @@ { "metric_id": "spending.pension_credit", "year": 2031, - "value": 6706505197.30149, + "value": 7463303552.117271, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pension_credit", @@ -2158,7 +2634,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2025, - "value": 1482756.0540938377, + "value": 1490774.5672746152, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2167,7 +2645,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2026, - "value": 1480853.1148245037, + "value": 1489116.733679533, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2176,7 +2656,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2027, - "value": 1468135.4329868257, + "value": 1477429.6021682024, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2185,7 +2667,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2028, - "value": 1447010.371819973, + "value": 1455339.335629344, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2194,7 +2678,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2029, - "value": 1447749.8420774192, + "value": 1457221.2425712496, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2203,7 +2689,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2030, - "value": 1444977.4954886883, + "value": 1454060.4666527808, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2212,7 +2700,9 @@ { "metric_id": "caseloads.pension_credit", "year": 2031, - "value": 1071942.0777988583, + "value": 1450518.8676682264, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pension_credit", @@ -2221,7 +2711,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2025, - "value": 1703858030.6710334, + "value": 1696782924.1721363, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2230,7 +2722,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2026, - "value": 1687685652.7937918, + "value": 1679283343.732223, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2239,7 +2733,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2027, - "value": 1616450871.1886823, + "value": 1590003738.0768867, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2248,7 +2744,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2028, - "value": 1595491327.4155288, + "value": 1573027080.7015598, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2257,7 +2755,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2029, - "value": 1589373766.706106, + "value": 1569952442.020166, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2266,7 +2766,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2030, - "value": 1572193208.341339, + "value": 1547501174.76131, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2275,7 +2777,9 @@ { "metric_id": "spending.winter_fuel_payment", "year": 2031, - "value": 1561450492.8642156, + "value": 1519503572.7274477, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "winter_fuel_payment", @@ -2284,7 +2788,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2025, - "value": 7112673.635766387, + "value": 7090970.121831879, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2293,7 +2799,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2026, - "value": 7034862.558265865, + "value": 7007600.1749506295, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2302,7 +2810,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2027, - "value": 6701440.449254692, + "value": 6593488.894350678, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2311,7 +2821,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2028, - "value": 6595675.344529897, + "value": 6508639.181520969, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2320,7 +2832,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2029, - "value": 6562245.612942323, + "value": 6490585.181142107, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2329,7 +2843,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2030, - "value": 6475253.517584786, + "value": 6377424.451988548, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2338,7 +2854,9 @@ { "metric_id": "caseloads.winter_fuel_payment", "year": 2031, - "value": 6396321.154249236, + "value": 6242475.78672789, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "winter_fuel_payment", @@ -2347,7 +2865,9 @@ { "metric_id": "spending.pawhp", "year": 2025, - "value": 195652682.01216725, + "value": 190004357.0627768, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2356,7 +2876,9 @@ { "metric_id": "spending.pawhp", "year": 2026, - "value": 203249126.6687394, + "value": 197376523.34808874, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2365,7 +2887,9 @@ { "metric_id": "spending.pawhp", "year": 2027, - "value": 207355049.2189332, + "value": 201464846.70418116, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2374,7 +2898,9 @@ { "metric_id": "spending.pawhp", "year": 2028, - "value": 211194062.34368283, + "value": 205237714.89489964, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2383,7 +2909,9 @@ { "metric_id": "spending.pawhp", "year": 2029, - "value": 216365284.1364411, + "value": 210263123.1305548, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2392,7 +2920,9 @@ { "metric_id": "spending.pawhp", "year": 2030, - "value": 221685784.68137968, + "value": 215433546.71501532, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2401,7 +2931,9 @@ { "metric_id": "spending.pawhp", "year": 2031, - "value": 227092425.0723793, + "value": 220687250.38609877, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pawhp", @@ -2410,7 +2942,9 @@ { "metric_id": "caseloads.pawhp", "year": 2025, - "value": 829304.9473122358, + "value": 806440.3659721613, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2419,7 +2953,9 @@ { "metric_id": "caseloads.pawhp", "year": 2026, - "value": 829534.2019373178, + "value": 806642.316529274, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2428,7 +2964,9 @@ { "metric_id": "caseloads.pawhp", "year": 2027, - "value": 829233.4100881219, + "value": 806823.3181906939, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2437,7 +2975,9 @@ { "metric_id": "caseloads.pawhp", "year": 2028, - "value": 827297.3866233826, + "value": 805142.1836649776, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2446,7 +2986,9 @@ { "metric_id": "caseloads.pawhp", "year": 2029, - "value": 830937.4991832376, + "value": 808684.8276723027, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2455,7 +2997,9 @@ { "metric_id": "caseloads.pawhp", "year": 2030, - "value": 834676.7390937805, + "value": 812323.9398192763, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2464,7 +3008,9 @@ { "metric_id": "caseloads.pawhp", "year": 2031, - "value": 838267.4468256831, + "value": 815816.9233164787, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pawhp", @@ -2473,7 +3019,9 @@ { "metric_id": "spending.universal_credit", "year": 2025, - "value": 79297069929.09415, + "value": 79166098634.76385, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2482,7 +3030,9 @@ { "metric_id": "spending.universal_credit", "year": 2026, - "value": 83589492640.30864, + "value": 83483281466.41425, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2491,7 +3041,9 @@ { "metric_id": "spending.universal_credit", "year": 2027, - "value": 85214410402.33775, + "value": 85110190875.9473, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2500,7 +3052,9 @@ { "metric_id": "spending.universal_credit", "year": 2028, - "value": 86751184502.34996, + "value": 86667339883.95622, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2509,7 +3063,9 @@ { "metric_id": "spending.universal_credit", "year": 2029, - "value": 87589733492.11055, + "value": 87402948162.99124, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2518,7 +3074,9 @@ { "metric_id": "spending.universal_credit", "year": 2030, - "value": 84522628500.50685, + "value": 84214027875.53, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2527,7 +3085,9 @@ { "metric_id": "spending.universal_credit", "year": 2031, - "value": 84959856870.21652, + "value": 85457636655.59526, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_credit", @@ -2536,7 +3096,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2025, - "value": 6348321.546875536, + "value": 6371587.519835666, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2545,7 +3107,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2026, - "value": 6360591.7880883515, + "value": 6379016.747252494, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2554,7 +3118,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2027, - "value": 6371549.96376504, + "value": 6394538.736335561, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2563,7 +3129,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2028, - "value": 6387956.646527201, + "value": 6399621.182661429, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2572,7 +3140,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2029, - "value": 6368112.714898095, + "value": 6373153.488585249, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2581,7 +3151,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2030, - "value": 6306509.543026373, + "value": 6316123.800888762, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2590,7 +3162,9 @@ { "metric_id": "caseloads.universal_credit", "year": 2031, - "value": 6290932.269934744, + "value": 6311765.528904498, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_credit", @@ -2599,7 +3173,9 @@ { "metric_id": "spending.housing_benefit", "year": 2025, - "value": 8156481113.662994, + "value": 8595335171.520943, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2608,7 +3184,9 @@ { "metric_id": "spending.housing_benefit", "year": 2026, - "value": 8481531600.187535, + "value": 8931864198.577654, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2617,7 +3195,9 @@ { "metric_id": "spending.housing_benefit", "year": 2027, - "value": 8730679159.952463, + "value": 9199477872.067867, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2626,7 +3206,9 @@ { "metric_id": "spending.housing_benefit", "year": 2028, - "value": 8864953021.672188, + "value": 9343759945.943094, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2635,7 +3217,9 @@ { "metric_id": "spending.housing_benefit", "year": 2029, - "value": 9128777068.594437, + "value": 9621753903.580982, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2644,7 +3228,9 @@ { "metric_id": "spending.housing_benefit", "year": 2030, - "value": 9364726526.233978, + "value": 9883322170.08307, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2653,7 +3239,9 @@ { "metric_id": "spending.housing_benefit", "year": 2031, - "value": 7910651616.326078, + "value": 10165697938.801252, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "housing_benefit", @@ -2662,7 +3250,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2025, - "value": 1231862.2140089273, + "value": 1254488.0164678842, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2671,7 +3261,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2026, - "value": 1232121.5733534694, + "value": 1252740.275393933, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2680,7 +3272,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2027, - "value": 1233595.1880552769, + "value": 1254335.1581986696, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2689,7 +3283,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2028, - "value": 1219421.9009352624, + "value": 1240028.0728783756, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2698,7 +3294,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2029, - "value": 1224496.4099243134, + "value": 1244626.769205615, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2707,7 +3305,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2030, - "value": 1225629.5051008165, + "value": 1246801.715999484, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2716,7 +3316,9 @@ { "metric_id": "caseloads.housing_benefit", "year": 2031, - "value": 1040899.9826000333, + "value": 1249258.3732767105, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "housing_benefit", @@ -2725,7 +3327,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2025, - "value": 2113520041.1605535, + "value": 1988515658.8280945, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2734,7 +3338,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2026, - "value": 2160301971.6342654, + "value": 2033885987.1873639, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2743,7 +3349,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2027, - "value": 2208773724.380773, + "value": 2079220973.3377244, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2752,7 +3360,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2028, - "value": 2263288488.6852794, + "value": 2130392978.7542346, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2761,7 +3371,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2029, - "value": 2318590052.1342373, + "value": 2182371431.2323685, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2770,7 +3382,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2030, - "value": 2383255853.6602764, + "value": 2245332431.4065833, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2779,7 +3393,9 @@ { "metric_id": "spending.council_tax_reduction", "year": 2031, - "value": 2140059095.905221, + "value": 2299546088.50543, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "council_tax_reduction", @@ -2788,7 +3404,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2025, - "value": 3390071.4158337414, + "value": 3187241.3228853345, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2797,7 +3415,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2026, - "value": 3414428.1972658634, + "value": 3211653.8483200073, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2806,7 +3426,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2027, - "value": 3432134.60831666, + "value": 3226461.487433046, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2815,7 +3437,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2028, - "value": 3454574.494795531, + "value": 3249134.5323915184, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2824,7 +3448,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2029, - "value": 3471370.6449995935, + "value": 3264581.2450436354, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2833,7 +3459,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2030, - "value": 3503850.509585738, + "value": 3296161.365950793, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2842,7 +3470,9 @@ { "metric_id": "caseloads.council_tax_reduction", "year": 2031, - "value": 3284019.218611926, + "value": 3310563.1543370783, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "council_tax_reduction", @@ -2851,7 +3481,9 @@ { "metric_id": "spending.esa_income", "year": 2025, - "value": 2157918345.897898, + "value": 2119219755.27102, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2860,7 +3492,9 @@ { "metric_id": "spending.esa_income", "year": 2026, - "value": 2215460941.2027354, + "value": 2175740626.0724893, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2869,7 +3503,9 @@ { "metric_id": "spending.esa_income", "year": 2027, - "value": 2267178997.307175, + "value": 2226624100.582522, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2878,7 +3514,9 @@ { "metric_id": "spending.esa_income", "year": 2028, - "value": 2318791077.751506, + "value": 2277324834.952569, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2887,7 +3525,9 @@ { "metric_id": "spending.esa_income", "year": 2029, - "value": 2374655132.983756, + "value": 2332267574.159017, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2896,7 +3536,9 @@ { "metric_id": "spending.esa_income", "year": 2030, - "value": 2432303236.337222, + "value": 2388837175.037817, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2905,7 +3547,9 @@ { "metric_id": "spending.esa_income", "year": 2031, - "value": 2490528072.8603873, + "value": 2446074707.6473985, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_income", @@ -2914,7 +3558,9 @@ { "metric_id": "caseloads.esa_income", "year": 2025, - "value": 188801.45329512656, + "value": 186830.98939606547, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2923,7 +3569,9 @@ { "metric_id": "caseloads.esa_income", "year": 2026, - "value": 189518.90480037034, + "value": 187540.95343746245, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2932,7 +3580,9 @@ { "metric_id": "caseloads.esa_income", "year": 2027, - "value": 190220.12851785123, + "value": 188234.8581573367, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2941,7 +3591,9 @@ { "metric_id": "caseloads.esa_income", "year": 2028, - "value": 190792.28100878, + "value": 188797.70078976452, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2950,7 +3602,9 @@ { "metric_id": "caseloads.esa_income", "year": 2029, - "value": 191631.7696904242, + "value": 189628.41371196508, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2959,7 +3613,9 @@ { "metric_id": "caseloads.esa_income", "year": 2030, - "value": 192494.11825346947, + "value": 190481.74765598774, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2968,7 +3624,9 @@ { "metric_id": "caseloads.esa_income", "year": 2031, - "value": 193322.20888361335, + "value": 191300.81845253706, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_income", @@ -2977,7 +3635,9 @@ { "metric_id": "spending.esa_contrib", "year": 2025, - "value": 5185465918.024606, + "value": 5221642514.164815, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -2986,7 +3646,9 @@ { "metric_id": "spending.esa_contrib", "year": 2026, - "value": 5324889662.558813, + "value": 5362038966.389469, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -2995,7 +3657,9 @@ { "metric_id": "spending.esa_contrib", "year": 2027, - "value": 5451483839.668319, + "value": 5489516286.663287, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -3004,7 +3668,9 @@ { "metric_id": "spending.esa_contrib", "year": 2028, - "value": 5582755219.611585, + "value": 5621703470.566074, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -3013,7 +3679,9 @@ { "metric_id": "spending.esa_contrib", "year": 2029, - "value": 5719465848.776321, + "value": 5759367828.016521, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -3022,7 +3690,9 @@ { "metric_id": "spending.esa_contrib", "year": 2030, - "value": 5860107436.9060955, + "value": 5900990569.133743, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -3031,7 +3701,9 @@ { "metric_id": "spending.esa_contrib", "year": 2031, - "value": 6003027242.14748, + "value": 6044892357.516051, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "esa_contrib", @@ -3040,7 +3712,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2025, - "value": 716681.2252025008, + "value": 721684.3135545999, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3049,7 +3723,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2026, - "value": 719404.6381566375, + "value": 724426.7398380041, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3058,7 +3734,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2027, - "value": 722066.4522648603, + "value": 727107.1310208887, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3067,7 +3745,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2028, - "value": 724954.6817337871, + "value": 730015.5208309293, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3076,7 +3756,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2029, - "value": 728144.496014744, + "value": 733227.5983880013, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3085,7 +3767,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2030, - "value": 731421.169972077, + "value": 736527.1409217119, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3094,7 +3778,9 @@ { "metric_id": "caseloads.esa_contrib", "year": 2031, - "value": 734567.6720936149, + "value": 739694.2040582746, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "esa_contrib", @@ -3103,7 +3789,9 @@ { "metric_id": "spending.jsa_income", "year": 2025, - "value": 2091746.573322478, + "value": 1446979.5182146362, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3113,6 +3801,8 @@ "metric_id": "spending.jsa_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3122,6 +3812,8 @@ "metric_id": "spending.jsa_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3131,6 +3823,8 @@ "metric_id": "spending.jsa_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3140,6 +3834,8 @@ "metric_id": "spending.jsa_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3149,6 +3845,8 @@ "metric_id": "spending.jsa_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3158,6 +3856,8 @@ "metric_id": "spending.jsa_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_income", @@ -3166,7 +3866,9 @@ { "metric_id": "caseloads.jsa_income", "year": 2025, - "value": 428.6262260079384, + "value": 278.16873490810394, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3176,6 +3878,8 @@ "metric_id": "caseloads.jsa_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3185,6 +3889,8 @@ "metric_id": "caseloads.jsa_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3194,6 +3900,8 @@ "metric_id": "caseloads.jsa_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3203,6 +3911,8 @@ "metric_id": "caseloads.jsa_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3212,6 +3922,8 @@ "metric_id": "caseloads.jsa_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3221,6 +3933,8 @@ "metric_id": "caseloads.jsa_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_income", @@ -3229,7 +3943,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2025, - "value": 297414211.9393128, + "value": 288777970.5356167, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3238,7 +3954,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2026, - "value": 305410902.6767736, + "value": 296542457.71586204, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3247,7 +3965,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2027, - "value": 312671758.9873353, + "value": 303592471.1494838, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3256,7 +3976,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2028, - "value": 320200875.2165054, + "value": 310902955.13914746, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3265,7 +3987,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2029, - "value": 328041963.6404445, + "value": 318516348.7643643, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3274,7 +3998,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2030, - "value": 336108514.2782755, + "value": 326348673.79918647, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3283,7 +4009,9 @@ { "metric_id": "spending.jsa_contrib", "year": 2031, - "value": 344305739.25844115, + "value": 334307010.21562654, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "jsa_contrib", @@ -3292,7 +4020,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2025, - "value": 69247.07556176186, + "value": 67182.8121483326, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3301,7 +4031,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2026, - "value": 69510.21602272987, + "value": 67438.10866451263, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3310,7 +4042,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2027, - "value": 69767.40603733063, + "value": 67687.63075757027, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3319,7 +4053,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2028, - "value": 70046.47099995613, + "value": 67958.37626743317, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3328,7 +4064,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2029, - "value": 70354.67849779129, + "value": 68257.39367341995, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3337,7 +4075,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2030, - "value": 70671.27628540993, + "value": 68564.55532884598, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3346,7 +4086,9 @@ { "metric_id": "caseloads.jsa_contrib", "year": 2031, - "value": 70975.29594826698, + "value": 68859.38249921799, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "jsa_contrib", @@ -3355,7 +4097,9 @@ { "metric_id": "spending.income_support", "year": 2025, - "value": 168861494.13127527, + "value": 123955194.70721354, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3365,6 +4109,8 @@ "metric_id": "spending.income_support", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3374,6 +4120,8 @@ "metric_id": "spending.income_support", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3383,6 +4131,8 @@ "metric_id": "spending.income_support", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3392,6 +4142,8 @@ "metric_id": "spending.income_support", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3401,6 +4153,8 @@ "metric_id": "spending.income_support", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3410,6 +4164,8 @@ "metric_id": "spending.income_support", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "income_support", @@ -3418,7 +4174,9 @@ { "metric_id": "caseloads.income_support", "year": 2025, - "value": 34027.1418364048, + "value": 24803.04834920168, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3428,6 +4186,8 @@ "metric_id": "caseloads.income_support", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3437,6 +4197,8 @@ "metric_id": "caseloads.income_support", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3446,6 +4208,8 @@ "metric_id": "caseloads.income_support", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3455,6 +4219,8 @@ "metric_id": "caseloads.income_support", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3464,6 +4230,8 @@ "metric_id": "caseloads.income_support", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3473,6 +4241,8 @@ "metric_id": "caseloads.income_support", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "income_support", @@ -3482,6 +4252,8 @@ "metric_id": "spending.working_tax_credit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3491,6 +4263,8 @@ "metric_id": "spending.working_tax_credit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3500,6 +4274,8 @@ "metric_id": "spending.working_tax_credit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3509,6 +4285,8 @@ "metric_id": "spending.working_tax_credit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3518,6 +4296,8 @@ "metric_id": "spending.working_tax_credit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3527,6 +4307,8 @@ "metric_id": "spending.working_tax_credit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3536,6 +4318,8 @@ "metric_id": "spending.working_tax_credit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "working_tax_credit", @@ -3545,6 +4329,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3554,6 +4340,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3563,6 +4351,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3572,6 +4362,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3581,6 +4373,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3590,6 +4384,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3599,6 +4395,8 @@ "metric_id": "caseloads.working_tax_credit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "working_tax_credit", @@ -3608,6 +4406,8 @@ "metric_id": "spending.child_tax_credit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3617,6 +4417,8 @@ "metric_id": "spending.child_tax_credit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3626,6 +4428,8 @@ "metric_id": "spending.child_tax_credit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3635,6 +4439,8 @@ "metric_id": "spending.child_tax_credit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3644,6 +4450,8 @@ "metric_id": "spending.child_tax_credit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3653,6 +4461,8 @@ "metric_id": "spending.child_tax_credit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3662,6 +4472,8 @@ "metric_id": "spending.child_tax_credit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_tax_credit", @@ -3671,6 +4483,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3680,6 +4494,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3689,6 +4505,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3698,6 +4516,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3707,6 +4527,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3716,6 +4538,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3725,6 +4549,8 @@ "metric_id": "caseloads.child_tax_credit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_tax_credit", @@ -3734,6 +4560,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3743,6 +4571,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3752,6 +4582,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3761,6 +4593,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3770,6 +4604,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3779,6 +4615,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3788,6 +4626,8 @@ "metric_id": "spending.incapacity_benefit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "incapacity_benefit", @@ -3797,6 +4637,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3806,6 +4648,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3815,6 +4659,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3824,6 +4670,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3833,6 +4681,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3842,6 +4692,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3851,6 +4703,8 @@ "metric_id": "caseloads.incapacity_benefit", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "incapacity_benefit", @@ -3859,7 +4713,9 @@ { "metric_id": "spending.pip", "year": 2025, - "value": 26930742886.35225, + "value": 27050796518.958164, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3868,7 +4724,9 @@ { "metric_id": "spending.pip", "year": 2026, - "value": 28062341229.807953, + "value": 28187504820.35819, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3877,7 +4735,9 @@ { "metric_id": "spending.pip", "year": 2027, - "value": 28813932611.682465, + "value": 28942448438.6212, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3886,7 +4746,9 @@ { "metric_id": "spending.pip", "year": 2028, - "value": 29507723865.475834, + "value": 29639334288.533737, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3895,7 +4757,9 @@ { "metric_id": "spending.pip", "year": 2029, - "value": 30230279635.690178, + "value": 30365112782.59276, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3904,7 +4768,9 @@ { "metric_id": "spending.pip", "year": 2030, - "value": 30973577012.717224, + "value": 31111725337.197277, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3913,7 +4779,9 @@ { "metric_id": "spending.pip", "year": 2031, - "value": 31728969558.821815, + "value": 31870426624.368805, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "pip", @@ -3922,7 +4790,9 @@ { "metric_id": "caseloads.pip", "year": 2025, - "value": 3561191.43436794, + "value": 3581346.50325121, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3931,7 +4801,9 @@ { "metric_id": "caseloads.pip", "year": 2026, - "value": 3574724.089687288, + "value": 3594955.7513322085, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3940,7 +4812,9 @@ { "metric_id": "caseloads.pip", "year": 2027, - "value": 3587950.6320851147, + "value": 3608257.1502955705, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3949,7 +4823,9 @@ { "metric_id": "caseloads.pip", "year": 2028, - "value": 3602302.242634833, + "value": 3622690.0020009577, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3958,7 +4834,9 @@ { "metric_id": "caseloads.pip", "year": 2029, - "value": 3618152.4307922274, + "value": 3638629.8922510594, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3967,7 +4845,9 @@ { "metric_id": "caseloads.pip", "year": 2030, - "value": 3634434.2309794426, + "value": 3655003.834502399, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3976,7 +4856,9 @@ { "metric_id": "caseloads.pip", "year": 2031, - "value": 3650069.225597918, + "value": 3670720.34740828, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "pip", @@ -3985,7 +4867,9 @@ { "metric_id": "spending.dla", "year": 2025, - "value": 7484533549.186283, + "value": 7519515429.343498, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -3994,7 +4878,9 @@ { "metric_id": "spending.dla", "year": 2026, - "value": 7798317749.580545, + "value": 7834750092.336622, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4003,7 +4889,9 @@ { "metric_id": "spending.dla", "year": 2027, - "value": 8007179295.781675, + "value": 8044587394.8355665, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4012,7 +4900,9 @@ { "metric_id": "spending.dla", "year": 2028, - "value": 8199978749.538992, + "value": 8238287553.0089445, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4021,7 +4911,9 @@ { "metric_id": "spending.dla", "year": 2029, - "value": 8400772001.314109, + "value": 8440018870.958048, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4030,7 +4922,9 @@ { "metric_id": "spending.dla", "year": 2030, - "value": 8607328838.619286, + "value": 8647540725.08345, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4039,7 +4933,9 @@ { "metric_id": "spending.dla", "year": 2031, - "value": 8817246875.370478, + "value": 8858422643.224787, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dla", @@ -4048,7 +4944,9 @@ { "metric_id": "caseloads.dla", "year": 2025, - "value": 1334888.5791397095, + "value": 1296293.76486139, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4057,7 +4955,9 @@ { "metric_id": "caseloads.dla", "year": 2026, - "value": 1339961.2010401338, + "value": 1301219.7249682397, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4066,7 +4966,9 @@ { "metric_id": "caseloads.dla", "year": 2027, - "value": 1344919.083814323, + "value": 1306034.2623415291, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4075,7 +4977,9 @@ { "metric_id": "caseloads.dla", "year": 2028, - "value": 1350298.687692389, + "value": 1311258.3262985945, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4084,7 +4988,9 @@ { "metric_id": "caseloads.dla", "year": 2029, - "value": 1356240.0184779614, + "value": 1317027.8841654807, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4093,7 +4999,9 @@ { "metric_id": "caseloads.dla", "year": 2030, - "value": 1362343.138695091, + "value": 1322954.550306335, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4102,7 +5010,9 @@ { "metric_id": "caseloads.dla", "year": 2031, - "value": 1368203.8070302308, + "value": 1328643.2488841116, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "dla", @@ -4111,7 +5021,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2025, - "value": 8738849002.944973, + "value": 8713069613.537264, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4120,7 +5032,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2026, - "value": 9105426589.661386, + "value": 9078584120.844625, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4129,7 +5043,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2027, - "value": 9349296216.992481, + "value": 9321734807.6616, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4138,7 +5054,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2028, - "value": 9574411518.877106, + "value": 9546186520.166021, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4147,7 +5065,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2029, - "value": 9808860827.118614, + "value": 9779944679.35845, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4156,7 +5076,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2030, - "value": 10050039243.53858, + "value": 10020412141.96302, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4165,7 +5087,9 @@ { "metric_id": "spending.attendance_allowance", "year": 2031, - "value": 10295142509.771086, + "value": 10264773293.632841, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "attendance_allowance", @@ -4174,7 +5098,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2025, - "value": 1716598.859297052, + "value": 1701405.523672521, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4183,7 +5109,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2026, - "value": 1723121.9867911339, + "value": 1707870.9254432172, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4192,7 +5120,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2027, - "value": 1729497.5712008923, + "value": 1714190.0767204762, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4201,7 +5131,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2028, - "value": 1736415.4675818533, + "value": 1721046.7521996498, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4210,7 +5142,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2029, - "value": 1744055.7271453738, + "value": 1728619.3880579919, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4219,7 +5153,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2030, - "value": 1751904.025848925, + "value": 1736398.2265275419, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4228,7 +5164,9 @@ { "metric_id": "caseloads.attendance_allowance", "year": 2031, - "value": 1759440.5596084595, + "value": 1743864.7361019552, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "attendance_allowance", @@ -4237,7 +5175,9 @@ { "metric_id": "spending.carers_allowance", "year": 2025, - "value": 4648737205.678978, + "value": 4498304384.942733, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4246,7 +5186,9 @@ { "metric_id": "spending.carers_allowance", "year": 2026, - "value": 4842862964.368323, + "value": 4686148233.621847, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4255,7 +5197,9 @@ { "metric_id": "spending.carers_allowance", "year": 2027, - "value": 4972568907.329457, + "value": 4811656899.117326, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4264,7 +5208,9 @@ { "metric_id": "spending.carers_allowance", "year": 2028, - "value": 5092300033.036156, + "value": 4927513579.343729, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4273,7 +5219,9 @@ { "metric_id": "spending.carers_allowance", "year": 2029, - "value": 5216995990.439329, + "value": 5048174373.178411, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4282,7 +5230,9 @@ { "metric_id": "spending.carers_allowance", "year": 2030, - "value": 5345270444.763329, + "value": 5168683430.088493, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4291,7 +5241,9 @@ { "metric_id": "spending.carers_allowance", "year": 2031, - "value": 5475632307.34242, + "value": 5294728594.298446, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carers_allowance", @@ -4300,7 +5252,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2025, - "value": 1073214.7707251012, + "value": 1038485.6135211289, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4309,7 +5263,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2026, - "value": 1077293.0261095166, + "value": 1042431.8938074112, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4318,7 +5274,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2027, - "value": 1081279.032247901, + "value": 1046288.9127022028, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4327,7 +5285,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2028, - "value": 1085604.089460224, + "value": 1050474.0211501122, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4336,7 +5296,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2029, - "value": 1090380.7663949132, + "value": 1055096.1227512658, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4345,7 +5307,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2030, - "value": 1095287.5083169043, + "value": 1059103.4548994005, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4354,7 +5318,9 @@ { "metric_id": "caseloads.carers_allowance", "year": 2031, - "value": 1099999.3295924067, + "value": 1063657.5973686576, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carers_allowance", @@ -4363,7 +5329,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2025, - "value": 375132605.30346316, + "value": 352284662.9714799, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4372,7 +5340,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2026, - "value": 390797693.02699834, + "value": 366995656.0579343, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4381,7 +5351,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2027, - "value": 401264392.71339667, + "value": 376824870.322693, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4390,7 +5362,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2028, - "value": 410926172.48663545, + "value": 385898182.16357577, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4399,7 +5373,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2029, - "value": 420988584.3188077, + "value": 395347728.7181926, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4408,7 +5384,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2030, - "value": 431339766.7553348, + "value": 405068460.50986433, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4417,7 +5395,9 @@ { "metric_id": "spending.carer_support_payment", "year": 2031, - "value": 441859394.4117867, + "value": 414946587.8089589, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "carer_support_payment", @@ -4426,7 +5406,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2025, - "value": 86603.70229154825, + "value": 81328.99044907093, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4435,7 +5417,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2026, - "value": 86932.79830861092, + "value": 81638.04422974586, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4444,7 +5428,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2027, - "value": 87254.451836586, + "value": 81940.10755866766, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4453,7 +5439,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2028, - "value": 87603.46610051394, + "value": 82267.86362826824, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4462,7 +5450,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2029, - "value": 87988.92237108946, + "value": 82629.84296369553, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4471,7 +5461,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2030, - "value": 88384.87467557192, + "value": 83001.67959588766, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4480,7 +5472,9 @@ { "metric_id": "caseloads.carer_support_payment", "year": 2031, - "value": 88765.09786373377, + "value": 83358.58633065224, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "carer_support_payment", @@ -4490,6 +5484,8 @@ "metric_id": "spending.scottish_carer_supplement", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4498,7 +5494,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2026, - "value": 52889916.61334165, + "value": 49668588.102493696, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4507,7 +5505,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2027, - "value": 53085610.62761456, + "value": 49852363.43918431, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4516,7 +5516,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2028, - "value": 53297950.914309174, + "value": 50051770.239931166, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4525,7 +5527,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2029, - "value": 53532462.518737875, + "value": 50271998.47644251, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4534,7 +5538,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2030, - "value": 53773359.910451815, + "value": 50498223.892546244, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4543,7 +5549,9 @@ { "metric_id": "spending.scottish_carer_supplement", "year": 2031, - "value": 54004687.70741227, + "value": 50715365.95869056, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_carer_supplement", @@ -4553,6 +5561,8 @@ "metric_id": "caseloads.scottish_carer_supplement", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4561,7 +5571,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2026, - "value": 86932.79830861092, + "value": 81638.04422974586, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4570,7 +5582,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2027, - "value": 87254.451836586, + "value": 81940.10755866766, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4579,7 +5593,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2028, - "value": 87603.46610051394, + "value": 82267.86362826824, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4588,7 +5604,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2029, - "value": 87988.92237108946, + "value": 82629.84296369553, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4597,7 +5615,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2030, - "value": 88384.87467557192, + "value": 83001.67959588766, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4606,7 +5626,9 @@ { "metric_id": "caseloads.scottish_carer_supplement", "year": 2031, - "value": 88765.09786373377, + "value": 83358.58633065224, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_carer_supplement", @@ -4615,7 +5637,9 @@ { "metric_id": "spending.sda", "year": 2025, - "value": 22916850.46898842, + "value": 26001911.186065674, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4624,7 +5648,9 @@ { "metric_id": "spending.sda", "year": 2026, - "value": 23874085.23946771, + "value": 27088007.985394344, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4633,7 +5659,9 @@ { "metric_id": "spending.sda", "year": 2027, - "value": 24513501.268621735, + "value": 27813501.58527428, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4642,7 +5670,9 @@ { "metric_id": "spending.sda", "year": 2028, - "value": 25103746.46338744, + "value": 28483206.305987563, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4651,7 +5681,9 @@ { "metric_id": "spending.sda", "year": 2029, - "value": 25718463.554498497, + "value": 29180676.754239403, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4660,7 +5692,9 @@ { "metric_id": "spending.sda", "year": 2030, - "value": 26350824.69866039, + "value": 29898167.047436927, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4669,7 +5703,9 @@ { "metric_id": "spending.sda", "year": 2031, - "value": 26993475.082385655, + "value": 30627272.264840692, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "sda", @@ -4678,7 +5714,9 @@ { "metric_id": "caseloads.sda", "year": 2025, - "value": 3832.2492423057556, + "value": 4348.145683288574, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4687,7 +5725,9 @@ { "metric_id": "caseloads.sda", "year": 2026, - "value": 3846.8119764328003, + "value": 4364.668739795685, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4696,7 +5736,9 @@ { "metric_id": "caseloads.sda", "year": 2027, - "value": 3861.0452857017517, + "value": 4380.818064212799, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4705,7 +5747,9 @@ { "metric_id": "caseloads.sda", "year": 2028, - "value": 3876.4891839027405, + "value": 4398.341153144836, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4714,7 +5758,9 @@ { "metric_id": "caseloads.sda", "year": 2029, - "value": 3893.5458583831787, + "value": 4417.694038391113, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4723,7 +5769,9 @@ { "metric_id": "caseloads.sda", "year": 2030, - "value": 3911.066900253296, + "value": 4437.573884487152, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4732,7 +5780,9 @@ { "metric_id": "caseloads.sda", "year": 2031, - "value": 3927.8920154571533, + "value": 4456.655462741852, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "sda", @@ -4741,7 +5791,9 @@ { "metric_id": "spending.iidb", "year": 2025, - "value": 507122677.50677097, + "value": 505396316.7142715, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4750,7 +5802,9 @@ { "metric_id": "spending.iidb", "year": 2026, - "value": 520757913.07925874, + "value": 518985130.7359581, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4759,7 +5813,9 @@ { "metric_id": "spending.iidb", "year": 2027, - "value": 533138408.2492215, + "value": 531323482.42515385, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4768,7 +5824,9 @@ { "metric_id": "spending.iidb", "year": 2028, - "value": 545976365.926789, + "value": 544117734.2383146, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4777,7 +5835,9 @@ { "metric_id": "spending.iidb", "year": 2029, - "value": 559346215.9575605, + "value": 557442066.9128101, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4786,7 +5846,9 @@ { "metric_id": "spending.iidb", "year": 2030, - "value": 573100581.8244832, + "value": 571149605.5284191, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4795,7 +5857,9 @@ { "metric_id": "spending.iidb", "year": 2031, - "value": 587077716.6684005, + "value": 585077668.0455391, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "iidb", @@ -4804,7 +5868,9 @@ { "metric_id": "caseloads.iidb", "year": 2025, - "value": 136309.47050744295, + "value": 137473.5583550036, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4813,7 +5879,9 @@ { "metric_id": "caseloads.iidb", "year": 2026, - "value": 136827.45147165656, + "value": 137995.96156597137, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4822,7 +5890,9 @@ { "metric_id": "caseloads.iidb", "year": 2027, - "value": 137333.71539336443, + "value": 138506.54905071855, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4831,7 +5901,9 @@ { "metric_id": "caseloads.iidb", "year": 2028, - "value": 137883.04356488585, + "value": 139060.56764179468, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4840,7 +5912,9 @@ { "metric_id": "caseloads.iidb", "year": 2029, - "value": 138489.7313208878, + "value": 139672.4357754588, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4849,7 +5923,9 @@ { "metric_id": "caseloads.iidb", "year": 2030, - "value": 139112.9398522377, + "value": 140300.96557214856, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4858,7 +5934,9 @@ { "metric_id": "caseloads.iidb", "year": 2031, - "value": 139711.38968542218, + "value": 140904.25975701213, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "iidb", @@ -4867,7 +5945,9 @@ { "metric_id": "spending.afcs", "year": 2025, - "value": 568151093.7335573, + "value": 608337715.55495, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4876,7 +5956,9 @@ { "metric_id": "spending.afcs", "year": 2026, - "value": 583427211.5685618, + "value": 624694356.9666108, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4885,7 +5967,9 @@ { "metric_id": "spending.afcs", "year": 2027, - "value": 597297627.7972465, + "value": 639545878.8404102, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4894,7 +5978,9 @@ { "metric_id": "spending.afcs", "year": 2028, - "value": 611680516.7185693, + "value": 654946115.3175836, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4903,7 +5989,9 @@ { "metric_id": "spending.afcs", "year": 2029, - "value": 626659337.1968452, + "value": 670984438.3106201, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4912,7 +6000,9 @@ { "metric_id": "spending.afcs", "year": 2030, - "value": 642068929.7703295, + "value": 687483985.4915761, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4921,7 +6011,9 @@ { "metric_id": "spending.afcs", "year": 2031, - "value": 657728126.7655065, + "value": 704248973.1763016, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "afcs", @@ -4930,7 +6022,9 @@ { "metric_id": "caseloads.afcs", "year": 2025, - "value": 87231.42297008634, + "value": 93556.20288914442, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4939,7 +6033,9 @@ { "metric_id": "caseloads.afcs", "year": 2026, - "value": 87562.9048769474, + "value": 93911.72057116032, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4948,7 +6044,9 @@ { "metric_id": "caseloads.afcs", "year": 2027, - "value": 87886.88816124201, + "value": 94259.19729825854, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4957,7 +6055,9 @@ { "metric_id": "caseloads.afcs", "year": 2028, - "value": 88238.43054047227, + "value": 94636.23070755601, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4966,7 +6066,9 @@ { "metric_id": "caseloads.afcs", "year": 2029, - "value": 88626.6806499958, + "value": 95052.63239446282, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4975,7 +6077,9 @@ { "metric_id": "caseloads.afcs", "year": 2030, - "value": 89025.50376203656, + "value": 95480.37335321307, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4984,7 +6088,9 @@ { "metric_id": "caseloads.afcs", "year": 2031, - "value": 89408.48312401772, + "value": 95890.9378361702, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "afcs", @@ -4993,7 +6099,9 @@ { "metric_id": "spending.child_benefit", "year": 2025, - "value": 16225320972.639776, + "value": 16193470845.603186, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5002,7 +6110,9 @@ { "metric_id": "spending.child_benefit", "year": 2026, - "value": 16896152580.691387, + "value": 16865082606.210407, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5011,7 +6121,9 @@ { "metric_id": "spending.child_benefit", "year": 2027, - "value": 17341380924.037914, + "value": 17309224587.32687, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5020,7 +6132,9 @@ { "metric_id": "spending.child_benefit", "year": 2028, - "value": 17755008028.657654, + "value": 17722732649.511974, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5029,7 +6143,9 @@ { "metric_id": "spending.child_benefit", "year": 2029, - "value": 18184587501.076538, + "value": 18149355497.8589, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5038,7 +6154,9 @@ { "metric_id": "spending.child_benefit", "year": 2030, - "value": 18623367766.380272, + "value": 18589683648.812527, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5047,7 +6165,9 @@ { "metric_id": "spending.child_benefit", "year": 2031, - "value": 19051556819.483387, + "value": 19019347515.30189, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "child_benefit", @@ -5056,7 +6176,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2025, - "value": 8144503.8247400075, + "value": 8124933.04077065, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5065,7 +6187,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2026, - "value": 8170465.289068207, + "value": 8151661.30214186, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5074,7 +6198,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2027, - "value": 8197312.676083803, + "value": 8178496.28433007, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5083,7 +6209,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2028, - "value": 8228525.797302961, + "value": 8209901.52580525, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5092,7 +6220,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2029, - "value": 8262403.058013514, + "value": 8242994.678169802, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5101,7 +6231,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2030, - "value": 8295971.741483018, + "value": 8277445.224295765, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5110,7 +6242,9 @@ { "metric_id": "caseloads.child_benefit", "year": 2031, - "value": 8318393.685991436, + "value": 8301138.966311127, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "child_benefit", @@ -5119,7 +6253,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2025, - "value": 497726869.8419192, + "value": 496826543.81597674, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5128,7 +6264,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2026, - "value": 519612809.90459955, + "value": 518442587.758203, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5137,7 +6275,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2027, - "value": 533556568.34392536, + "value": 532354940.6639104, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5146,7 +6286,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2028, - "value": 546831672.8782737, + "value": 545600150.271133, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5155,7 +6297,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2029, - "value": 557690457.6552503, + "value": 557257209.815226, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5164,7 +6308,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2030, - "value": 571385467.1006676, + "value": 570941579.8039502, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5173,7 +6319,9 @@ { "metric_id": "spending.scottish_child_payment", "year": 2031, - "value": 579782864.4065932, + "value": 579837131.7746521, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "scottish_child_payment", @@ -5182,7 +6330,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2025, - "value": 352547.7069185972, + "value": 351909.9919484854, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5191,7 +6341,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2026, - "value": 354345.8819241524, + "value": 353547.8581060171, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5200,7 +6352,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2027, - "value": 355656.9695440531, + "value": 354855.99119508266, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5209,7 +6363,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2028, - "value": 357079.5769625902, + "value": 356275.3960905075, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5218,7 +6374,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2029, - "value": 356899.05693626404, + "value": 356621.7960590124, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5227,7 +6385,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2030, - "value": 358505.11331129074, + "value": 358226.60453784466, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5236,7 +6396,9 @@ { "metric_id": "caseloads.scottish_child_payment", "year": 2031, - "value": 356640.845736742, + "value": 356674.22713041306, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "scottish_child_payment", @@ -5246,6 +6408,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5255,6 +6419,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5264,6 +6430,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5273,6 +6441,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5282,6 +6452,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5291,6 +6463,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5300,6 +6474,8 @@ "metric_id": "spending.two_child_limit_payment", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "two_child_limit_payment", @@ -5309,6 +6485,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5318,6 +6496,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5327,6 +6507,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5336,6 +6518,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5345,6 +6529,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5354,6 +6540,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5363,6 +6551,8 @@ "metric_id": "caseloads.two_child_limit_payment", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "two_child_limit_payment", @@ -5371,7 +6561,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2025, - "value": 53970826.290031455, + "value": 52030589.486631, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5380,7 +6572,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2026, - "value": 54175917.177720994, + "value": 52228307.60199582, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5389,7 +6583,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2027, - "value": 54376369.043138646, + "value": 52421553.321768366, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5398,7 +6594,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2028, - "value": 54593871.5011113, + "value": 52631236.984368056, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5407,7 +6605,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2029, - "value": 54834085.12924674, + "value": 52862815.027035154, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5416,7 +6616,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2030, - "value": 55080840.175692044, + "value": 53100698.99261543, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5425,7 +6627,9 @@ { "metric_id": "spending.healthy_start_vouchers", "year": 2031, - "value": 55317793.086847685, + "value": 53329031.82253713, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "healthy_start_vouchers", @@ -5434,7 +6638,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2025, - "value": 208602.1595891416, + "value": 200297.54610317945, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5443,7 +6649,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2026, - "value": 209394.85441070795, + "value": 201058.68415096402, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5452,7 +6660,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2027, - "value": 210169.61887291074, + "value": 201802.60497659445, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5461,7 +6671,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2028, - "value": 211010.2855282426, + "value": 202609.80542340875, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5470,7 +6682,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2029, - "value": 211938.73312041163, + "value": 203501.29058858752, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5479,7 +6693,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2030, - "value": 212892.46397477388, + "value": 204417.0512445569, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5488,7 +6704,9 @@ { "metric_id": "caseloads.healthy_start_vouchers", "year": 2031, - "value": 213808.30855458975, + "value": 205296.04370743036, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "healthy_start_vouchers", @@ -5498,6 +6716,8 @@ "metric_id": "spending.maternity_allowance", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5507,6 +6727,8 @@ "metric_id": "spending.maternity_allowance", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5516,6 +6738,8 @@ "metric_id": "spending.maternity_allowance", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5525,6 +6749,8 @@ "metric_id": "spending.maternity_allowance", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5534,6 +6760,8 @@ "metric_id": "spending.maternity_allowance", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5543,6 +6771,8 @@ "metric_id": "spending.maternity_allowance", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5552,6 +6782,8 @@ "metric_id": "spending.maternity_allowance", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "maternity_allowance", @@ -5561,6 +6793,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5570,6 +6804,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5579,6 +6815,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5588,6 +6826,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5597,6 +6837,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5606,6 +6848,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5615,6 +6859,8 @@ "metric_id": "caseloads.maternity_allowance", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "maternity_allowance", @@ -5623,7 +6869,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2025, - "value": 3337974284.302048, + "value": 3318182317.450847, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5632,7 +6880,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2026, - "value": 3427723791.796866, + "value": 3407399609.1208797, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5641,7 +6891,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2027, - "value": 3509214573.900618, + "value": 3488407179.3860164, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5650,7 +6902,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2028, - "value": 3593716240.856939, + "value": 3572407800.478435, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5659,7 +6913,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2029, - "value": 3681719191.086521, + "value": 3659888978.871894, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5668,7 +6924,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2030, - "value": 3772252863.671072, + "value": 3749885904.2724175, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5677,7 +6935,9 @@ { "metric_id": "spending.statutory_maternity_pay", "year": 2031, - "value": 3864252917.3662605, + "value": 3841330547.982543, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_maternity_pay", @@ -5686,7 +6946,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2025, - "value": 321312.8473469913, + "value": 333845.906548053, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5695,7 +6957,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2026, - "value": 322533.84839725494, + "value": 335114.53039878607, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5704,7 +6968,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2027, - "value": 323727.22941863537, + "value": 336354.45804458857, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5713,7 +6979,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2028, - "value": 325022.1207540333, + "value": 337699.85739299655, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5722,7 +6990,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2029, - "value": 326452.22118473053, + "value": 339185.7411582172, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5731,7 +7001,9 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2030, - "value": 327921.2663208842, + "value": 340712.0907050073, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", @@ -5740,16 +7012,174 @@ { "metric_id": "caseloads.statutory_maternity_pay", "year": 2031, - "value": 329331.95074787736, + "value": 342177.1517958939, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_maternity_pay", "label": "Statutory Maternity Pay" }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2025, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2026, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2027, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2028, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2029, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2030, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "spending.statutory_paternity_pay", + "year": 2031, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "gbp", + "kind": "spending", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2025, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2026, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2027, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2028, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2029, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2030, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, + { + "metric_id": "caseloads.statutory_paternity_pay", + "year": 2031, + "value": 0.0, + "suppressed": false, + "excludes": [], + "unit": "count", + "kind": "caseload", + "partition": "statutory_paternity_pay", + "label": "Statutory Paternity Pay" + }, { "metric_id": "spending.statutory_sick_pay", "year": 2025, - "value": 1179772370.051021, + "value": 1129666049.2058818, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5758,7 +7188,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2026, - "value": 1211493383.3111763, + "value": 1160039811.2081735, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5767,7 +7199,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2027, - "value": 1240295431.5737867, + "value": 1187618628.8419445, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5776,7 +7210,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2028, - "value": 1270161726.4028587, + "value": 1216216439.2552707, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5785,7 +7221,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2029, - "value": 1301265465.0001216, + "value": 1245999166.4642668, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5794,7 +7232,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2030, - "value": 1333263686.1531434, + "value": 1276638374.6988342, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5803,7 +7243,9 @@ { "metric_id": "spending.statutory_sick_pay", "year": 2031, - "value": 1365780194.8969457, + "value": 1307770469.3172994, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "statutory_sick_pay", @@ -5812,7 +7254,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2025, - "value": 185810.46695950627, + "value": 181849.49628841877, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5821,7 +7265,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2026, - "value": 186516.55576607585, + "value": 182540.52988168597, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5830,7 +7276,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2027, - "value": 187206.66695040464, + "value": 183215.93420261145, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5839,7 +7287,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2028, - "value": 187955.48840495944, + "value": 183948.78862804174, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5848,7 +7298,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2029, - "value": 188782.49710434675, + "value": 184758.16779726744, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5857,7 +7309,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2030, - "value": 189632.02460759878, + "value": 185589.58406233788, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5866,7 +7320,9 @@ { "metric_id": "caseloads.statutory_sick_pay", "year": 2031, - "value": 190447.80656796694, + "value": 186387.61916735768, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "statutory_sick_pay", @@ -5875,7 +7331,9 @@ { "metric_id": "spending.ssmg", "year": 2025, - "value": 14081577.514007688, + "value": 13125578.55695486, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5884,7 +7342,9 @@ { "metric_id": "spending.ssmg", "year": 2026, - "value": 14135088.059857488, + "value": 13175456.283032894, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5893,7 +7353,9 @@ { "metric_id": "spending.ssmg", "year": 2027, - "value": 14187388.068765402, + "value": 13224205.684065819, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5902,7 +7364,9 @@ { "metric_id": "spending.ssmg", "year": 2028, - "value": 14244136.843547225, + "value": 13277101.755604148, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5911,7 +7375,9 @@ { "metric_id": "spending.ssmg", "year": 2029, - "value": 14306811.286181211, + "value": 13335521.275147796, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5920,7 +7386,9 @@ { "metric_id": "spending.ssmg", "year": 2030, - "value": 14371192.14501977, + "value": 13395531.273916364, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5929,7 +7397,9 @@ { "metric_id": "spending.ssmg", "year": 2031, - "value": 14433015.649303794, + "value": 13453131.819739938, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "ssmg", @@ -5938,7 +7408,9 @@ { "metric_id": "caseloads.ssmg", "year": 2025, - "value": 28163.155028015375, + "value": 26251.15711390972, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5947,7 +7419,9 @@ { "metric_id": "caseloads.ssmg", "year": 2026, - "value": 28270.176119714975, + "value": 26350.91256606579, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5956,7 +7430,9 @@ { "metric_id": "caseloads.ssmg", "year": 2027, - "value": 28374.776137530804, + "value": 26448.411368131638, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5965,7 +7441,9 @@ { "metric_id": "caseloads.ssmg", "year": 2028, - "value": 28488.27368709445, + "value": 26554.203511208296, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5974,7 +7452,9 @@ { "metric_id": "caseloads.ssmg", "year": 2029, - "value": 28613.622572362423, + "value": 26671.04255029559, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5983,7 +7463,9 @@ { "metric_id": "caseloads.ssmg", "year": 2030, - "value": 28742.38429003954, + "value": 26791.062547832727, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -5992,7 +7474,9 @@ { "metric_id": "caseloads.ssmg", "year": 2031, - "value": 28866.031298607588, + "value": 26906.263639479876, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "ssmg", @@ -6001,7 +7485,9 @@ { "metric_id": "spending.bsp", "year": 2025, - "value": 654447661.0694321, + "value": 670117436.1645457, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6010,7 +7496,9 @@ { "metric_id": "spending.bsp", "year": 2026, - "value": 672044090.7987709, + "value": 688135183.9490275, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6019,7 +7507,9 @@ { "metric_id": "spending.bsp", "year": 2027, - "value": 688021282.8463899, + "value": 704494923.9662163, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6028,7 +7518,9 @@ { "metric_id": "spending.bsp", "year": 2028, - "value": 704588777.7902418, + "value": 721459099.2289677, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6037,7 +7529,9 @@ { "metric_id": "spending.bsp", "year": 2029, - "value": 721842764.085712, + "value": 739126209.3696653, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6046,7 +7540,9 @@ { "metric_id": "spending.bsp", "year": 2030, - "value": 739592896.5915165, + "value": 757301345.0579464, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6055,7 +7551,9 @@ { "metric_id": "spending.bsp", "year": 2031, - "value": 757630569.6200083, + "value": 775768909.9039654, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bsp", @@ -6064,7 +7562,9 @@ { "metric_id": "caseloads.bsp", "year": 2025, - "value": 92227.57971167564, + "value": 98107.17104572058, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6073,7 +7573,9 @@ { "metric_id": "caseloads.bsp", "year": 2026, - "value": 92578.04749596119, + "value": 98479.98137193918, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6082,7 +7584,9 @@ { "metric_id": "caseloads.bsp", "year": 2027, - "value": 92920.58816957474, + "value": 98844.35904490948, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6091,7 +7595,9 @@ { "metric_id": "caseloads.bsp", "year": 2028, - "value": 93292.26550346613, + "value": 99239.73120433092, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6100,7 +7606,9 @@ { "metric_id": "caseloads.bsp", "year": 2029, - "value": 93702.75265604258, + "value": 99676.38774478436, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6109,7 +7617,9 @@ { "metric_id": "caseloads.bsp", "year": 2030, - "value": 94124.41794991493, + "value": 100124.9348872304, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6118,7 +7628,9 @@ { "metric_id": "caseloads.bsp", "year": 2031, - "value": 94529.3324996233, + "value": 100555.47150915861, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bsp", @@ -6127,7 +7639,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2025, - "value": 563525940.8773441, + "value": 551460799.5583543, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6136,7 +7650,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2026, - "value": 568299173.1681643, + "value": 556759454.765603, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6145,7 +7661,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2027, - "value": 573445937.53699, + "value": 566094705.6608063, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6154,7 +7672,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2028, - "value": 585862809.08509, + "value": 577814275.3520343, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6163,7 +7683,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2029, - "value": 561084451.3935778, + "value": 546986429.725085, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6172,7 +7694,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2030, - "value": 580251499.0481504, + "value": 566235601.269679, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6181,7 +7705,9 @@ { "metric_id": "spending.tax_free_childcare", "year": 2031, - "value": 560322627.8579925, + "value": 577475011.8886111, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "tax_free_childcare", @@ -6190,7 +7716,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2025, - "value": 974133.2423208505, + "value": 1011592.7329891622, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6199,7 +7727,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2026, - "value": 979682.5571617931, + "value": 1021183.6429007053, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6208,7 +7738,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2027, - "value": 971293.5103004575, + "value": 1020334.1701015085, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6217,7 +7749,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2028, - "value": 976896.6010332257, + "value": 1023999.8023810685, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6226,7 +7760,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2029, - "value": 957558.250394702, + "value": 972793.2253160179, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6235,7 +7771,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2030, - "value": 966774.040873915, + "value": 980748.0028229207, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6244,7 +7782,9 @@ { "metric_id": "caseloads.tax_free_childcare", "year": 2031, - "value": 914534.0746401399, + "value": 980979.6002974808, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "tax_free_childcare", @@ -6253,7 +7793,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2025, - "value": 4769443952.411871, + "value": 2648748217.184513, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6262,7 +7804,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2026, - "value": 5307606110.253133, + "value": 3074999356.8385277, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6271,7 +7815,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2027, - "value": 5433878200.598173, + "value": 3151701029.7253556, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6280,7 +7826,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2028, - "value": 5575606222.716693, + "value": 3235525477.42588, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6289,7 +7837,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2029, - "value": 5682980459.519588, + "value": 3310370308.099014, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6298,7 +7848,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2030, - "value": 5856928114.420198, + "value": 3398248782.2036104, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6307,7 +7859,9 @@ { "metric_id": "spending.extended_childcare_entitlement", "year": 2031, - "value": 5926506204.517755, + "value": 3482394959.2131405, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "extended_childcare_entitlement", @@ -6316,7 +7870,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2025, - "value": 962176.1845641136, + "value": 664320.1943173409, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6325,7 +7881,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2026, - "value": 957668.9594607353, + "value": 669802.4551296234, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6334,7 +7892,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2027, - "value": 959484.9431653023, + "value": 672223.7327985764, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6343,7 +7903,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2028, - "value": 963973.5987744331, + "value": 673895.0757799149, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6352,7 +7914,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2029, - "value": 955768.6225590706, + "value": 676376.3725852966, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6361,7 +7925,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2030, - "value": 963198.3530750275, + "value": 681117.3323864937, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6370,7 +7936,9 @@ { "metric_id": "caseloads.extended_childcare_entitlement", "year": 2031, - "value": 953890.9335918427, + "value": 683408.1116919518, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "extended_childcare_entitlement", @@ -6379,7 +7947,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2025, - "value": 1303677004.8374069, + "value": 3266472521.328236, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6388,7 +7958,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2026, - "value": 1372546985.2564154, + "value": 3388720332.016673, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6397,7 +7969,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2027, - "value": 1412467299.31001, + "value": 3479027407.998313, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6406,7 +7980,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2028, - "value": 1447975936.2291465, + "value": 3565630277.573733, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6415,7 +7991,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2029, - "value": 1528607528.5243466, + "value": 3653067234.422063, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6424,7 +8002,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2030, - "value": 1568458954.5961401, + "value": 3757717185.8182206, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6433,7 +8013,9 @@ { "metric_id": "spending.universal_childcare_entitlement", "year": 2031, - "value": 1607433548.773115, + "value": 3846294804.2724795, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "universal_childcare_entitlement", @@ -6442,7 +8024,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2025, - "value": 379484.6813406944, + "value": 985445.4297113419, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6451,7 +8035,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2026, - "value": 386394.35359191895, + "value": 988718.9267745018, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6460,7 +8046,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2027, - "value": 388693.47555303574, + "value": 992135.8684687614, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6469,7 +8057,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2028, - "value": 390652.560110569, + "value": 996884.7139797211, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6478,7 +8068,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2029, - "value": 404320.32845020294, + "value": 1001194.633936882, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6487,7 +8079,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2030, - "value": 406727.4603328705, + "value": 1010204.9373459816, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6496,7 +8090,9 @@ { "metric_id": "caseloads.universal_childcare_entitlement", "year": 2031, - "value": 408660.8797736168, + "value": 1013771.0637845993, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "universal_childcare_entitlement", @@ -6505,7 +8101,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2025, - "value": 443196400.7338863, + "value": 572024783.1285633, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6514,7 +8112,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2026, - "value": 460006640.4923033, + "value": 593721428.6371526, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6523,7 +8123,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2027, - "value": 472326964.79148394, + "value": 609623032.498304, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6532,7 +8134,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2028, - "value": 483699822.07395244, + "value": 624301765.4036878, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6541,7 +8145,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2029, - "value": 495544205.88908577, + "value": 639589086.6096659, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6550,7 +8156,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2030, - "value": 497331070.06473446, + "value": 655315201.2710408, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6559,7 +8167,9 @@ { "metric_id": "spending.targeted_childcare_entitlement", "year": 2031, - "value": 504378844.1796443, + "value": 664824494.3626773, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "targeted_childcare_entitlement", @@ -6568,7 +8178,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2025, - "value": 91615.18099975586, + "value": 119291.62903356552, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6577,7 +8189,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2026, - "value": 91963.32138204575, + "value": 119744.94127082825, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6586,7 +8200,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2027, - "value": 92303.58706235886, + "value": 120188.00064563751, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6595,7 +8211,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2028, - "value": 92672.79612493515, + "value": 120668.74804401398, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6604,7 +8222,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2029, - "value": 93080.55707216263, + "value": 121199.69331502914, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6613,7 +8233,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2030, - "value": 91584.69786310196, + "value": 121745.09483098984, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6622,7 +8244,9 @@ { "metric_id": "caseloads.targeted_childcare_entitlement", "year": 2031, - "value": 91061.30740070343, + "value": 121100.23704528809, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "targeted_childcare_entitlement", @@ -6632,6 +8256,8 @@ "metric_id": "spending.care_to_learn", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6641,6 +8267,8 @@ "metric_id": "spending.care_to_learn", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6650,6 +8278,8 @@ "metric_id": "spending.care_to_learn", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6659,6 +8289,8 @@ "metric_id": "spending.care_to_learn", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6668,6 +8300,8 @@ "metric_id": "spending.care_to_learn", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6677,6 +8311,8 @@ "metric_id": "spending.care_to_learn", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6686,6 +8322,8 @@ "metric_id": "spending.care_to_learn", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "care_to_learn", @@ -6695,6 +8333,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6704,6 +8344,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6713,6 +8355,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6722,6 +8366,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6731,6 +8377,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6740,6 +8388,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6749,6 +8399,8 @@ "metric_id": "caseloads.care_to_learn", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "care_to_learn", @@ -6758,6 +8410,8 @@ "metric_id": "spending.childcare_grant", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6767,6 +8421,8 @@ "metric_id": "spending.childcare_grant", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6776,6 +8432,8 @@ "metric_id": "spending.childcare_grant", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6785,6 +8443,8 @@ "metric_id": "spending.childcare_grant", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6794,6 +8454,8 @@ "metric_id": "spending.childcare_grant", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6803,6 +8465,8 @@ "metric_id": "spending.childcare_grant", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6812,6 +8476,8 @@ "metric_id": "spending.childcare_grant", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "childcare_grant", @@ -6821,6 +8487,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6830,6 +8498,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6839,6 +8509,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6848,6 +8520,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6857,6 +8531,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6866,6 +8542,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6875,6 +8553,8 @@ "metric_id": "caseloads.childcare_grant", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "childcare_grant", @@ -6883,7 +8563,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2025, - "value": 200089205.6251793, + "value": 199163916.0120163, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6892,7 +8574,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2026, - "value": 200849551.41910172, + "value": 199920745.4944954, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6901,7 +8585,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2027, - "value": 201592698.65725708, + "value": 200660457.07904816, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6910,7 +8596,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2028, - "value": 202399059.3290062, + "value": 201463089.27254868, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6919,7 +8607,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2029, - "value": 203289618.08014297, + "value": 202349531.5649414, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6928,7 +8618,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2030, - "value": 167103353.08280182, + "value": 203188174.47846603, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6937,7 +8629,9 @@ { "metric_id": "spending.parents_learning_allowance", "year": 2031, - "value": 167822216.12641907, + "value": 204061882.82292938, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "parents_learning_allowance", @@ -6946,7 +8640,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2025, - "value": 98858.3031744957, + "value": 98401.14427471161, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -6955,7 +8651,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2026, - "value": 99233.96809244156, + "value": 98775.07188463211, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -6964,7 +8662,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2027, - "value": 99601.13570022583, + "value": 99140.54203510284, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -6973,7 +8673,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2028, - "value": 99999.53524160385, + "value": 99537.09944295883, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -6982,7 +8684,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2029, - "value": 100439.53462457657, + "value": 99975.0650024414, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -6991,7 +8695,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2030, - "value": 82560.94519901276, + "value": 100389.41426801682, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -7000,7 +8706,9 @@ { "metric_id": "caseloads.parents_learning_allowance", "year": 2031, - "value": 82916.11468696594, + "value": 100821.0883512497, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "parents_learning_allowance", @@ -7009,7 +8717,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2025, - "value": 37181344.76393461, + "value": 62362242.98413515, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7018,7 +8728,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2026, - "value": 37322635.825378895, + "value": 62599222.553822994, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7027,7 +8739,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2027, - "value": 37460730.62564373, + "value": 44187742.20506668, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7036,7 +8750,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2028, - "value": 37610571.710464954, + "value": 44364490.80733061, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7045,7 +8761,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2029, - "value": 37776058.51188898, + "value": 44559696.51651859, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7054,7 +8772,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2030, - "value": 37946051.452560425, + "value": 44760217.169840336, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7063,7 +8783,9 @@ { "metric_id": "spending.adult_dependants_grant", "year": 2031, - "value": 38109291.55965805, + "value": 44952685.864076614, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "adult_dependants_grant", @@ -7072,7 +8794,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2025, - "value": 10488.390624523163, + "value": 17591.605919361115, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7081,7 +8805,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2026, - "value": 10528.247059345245, + "value": 17658.454881191254, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7090,7 +8816,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2027, - "value": 10567.201869010925, + "value": 12464.807392120361, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7099,7 +8827,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2028, - "value": 10609.470158100128, + "value": 12514.665954113007, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7108,7 +8838,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2029, - "value": 10656.151907444, + "value": 12569.73103427887, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7117,7 +8849,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2030, - "value": 10704.104782104492, + "value": 12626.29539346695, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7126,7 +8860,9 @@ { "metric_id": "caseloads.adult_dependants_grant", "year": 2031, - "value": 10750.152767181396, + "value": 12680.588396072388, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "adult_dependants_grant", @@ -7136,6 +8872,8 @@ "metric_id": "spending.travel_grant", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7145,6 +8883,8 @@ "metric_id": "spending.travel_grant", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7154,6 +8894,8 @@ "metric_id": "spending.travel_grant", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7163,6 +8905,8 @@ "metric_id": "spending.travel_grant", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7172,6 +8916,8 @@ "metric_id": "spending.travel_grant", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7181,6 +8927,8 @@ "metric_id": "spending.travel_grant", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7190,6 +8938,8 @@ "metric_id": "spending.travel_grant", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "travel_grant", @@ -7199,6 +8949,8 @@ "metric_id": "caseloads.travel_grant", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7208,6 +8960,8 @@ "metric_id": "caseloads.travel_grant", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7217,6 +8971,8 @@ "metric_id": "caseloads.travel_grant", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7226,6 +8982,8 @@ "metric_id": "caseloads.travel_grant", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7235,6 +8993,8 @@ "metric_id": "caseloads.travel_grant", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7244,6 +9004,8 @@ "metric_id": "caseloads.travel_grant", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7253,6 +9015,8 @@ "metric_id": "caseloads.travel_grant", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "travel_grant", @@ -7261,7 +9025,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2025, - "value": 31398271.13342285, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7270,7 +9036,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2026, - "value": 31517584.991455078, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7279,7 +9047,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2027, - "value": 31634201.431274414, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7288,7 +9058,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2028, - "value": 31760736.053466797, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7297,7 +9069,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2029, - "value": 31900483.795166016, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7306,7 +9080,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2030, - "value": 32044037.338256836, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7315,7 +9091,9 @@ { "metric_id": "spending.disabled_students_allowance", "year": 2031, - "value": 32181888.01574707, + "value": null, + "suppressed": true, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disabled_students_allowance", @@ -7324,7 +9102,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2025, - "value": 1744.3483963012695, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7333,7 +9113,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2026, - "value": 1750.9769439697266, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7342,7 +9124,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2027, - "value": 1757.4556350708008, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7351,7 +9135,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2028, - "value": 1764.485336303711, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7360,7 +9146,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2029, - "value": 1772.2490997314453, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7369,7 +9157,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2030, - "value": 1780.2242965698242, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7378,7 +9168,9 @@ { "metric_id": "caseloads.disabled_students_allowance", "year": 2031, - "value": 1787.882667541504, + "value": null, + "suppressed": true, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disabled_students_allowance", @@ -7388,6 +9180,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7397,6 +9191,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7406,6 +9202,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7415,6 +9213,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7424,6 +9224,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7433,6 +9235,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7442,6 +9246,8 @@ "metric_id": "spending.bursary_fund_16_to_19", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "bursary_fund_16_to_19", @@ -7451,6 +9257,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7460,6 +9268,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7469,6 +9279,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7478,6 +9290,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7487,6 +9301,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7496,6 +9312,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7505,6 +9323,8 @@ "metric_id": "caseloads.bursary_fund_16_to_19", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "bursary_fund_16_to_19", @@ -7513,7 +9333,9 @@ { "metric_id": "spending.nhs_spending", "year": 2025, - "value": 205232332361.1096, + "value": 204681602159.08142, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7522,7 +9344,9 @@ { "metric_id": "spending.nhs_spending", "year": 2026, - "value": 206012222586.4446, + "value": 205459399297.22324, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7531,7 +9355,9 @@ { "metric_id": "spending.nhs_spending", "year": 2027, - "value": 206774471228.05975, + "value": 206219602604.32385, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7540,7 +9366,9 @@ { "metric_id": "spending.nhs_spending", "year": 2028, - "value": 207601558499.668, + "value": 207044470471.60086, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7549,7 +9377,9 @@ { "metric_id": "spending.nhs_spending", "year": 2029, - "value": 208515008515.17273, + "value": 207955469002.00323, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7558,7 +9388,9 @@ { "metric_id": "spending.nhs_spending", "year": 2030, - "value": 209453332677.40167, + "value": 208891275075.5078, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7567,7 +9399,9 @@ { "metric_id": "spending.nhs_spending", "year": 2031, - "value": 210354381149.51398, + "value": 209789506836.96567, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "nhs_spending", @@ -7576,7 +9410,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2025, - "value": 33304098.421608984, + "value": 23991604.347275984, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7585,7 +9421,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2026, - "value": 33430655.237596173, + "value": 24082773.488280036, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7594,7 +9432,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2027, - "value": 33554349.3340033, + "value": 24171879.76151417, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7603,7 +9443,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2028, - "value": 33688564.45852884, + "value": 24268565.8560597, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7612,7 +9454,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2029, - "value": 33836794.51014231, + "value": 24375348.2293824, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7621,7 +9465,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2030, - "value": 33989060.95758362, + "value": 24485037.725156102, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7630,7 +9476,9 @@ { "metric_id": "spending.dfe_education_spending", "year": 2031, - "value": 34135278.81945166, + "value": 24590323.320610803, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dfe_education_spending", @@ -7639,7 +9487,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2025, - "value": 25151126510.800682, + "value": 25151126494.365833, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7648,7 +9498,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2026, - "value": 25246701591.88542, + "value": 25246701555.2342, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7657,7 +9509,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2027, - "value": 25340114855.52831, + "value": 25340114784.780163, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7666,7 +9520,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2028, - "value": 25441474019.590816, + "value": 25441473946.770885, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7675,7 +9531,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2029, - "value": 25553416910.70827, + "value": 25553416805.737003, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7684,7 +9542,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2030, - "value": 25668408066.936726, + "value": 25668407910.982876, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7693,7 +9553,9 @@ { "metric_id": "spending.dft_subsidy_spending", "year": 2031, - "value": 30944615266.675636, + "value": 25778782013.14279, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "dft_subsidy_spending", @@ -7703,6 +9565,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7712,6 +9576,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7721,6 +9587,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7730,6 +9598,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7739,6 +9609,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7748,6 +9620,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7757,6 +9631,8 @@ "metric_id": "spending.cost_of_living_support_payment", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "cost_of_living_support_payment", @@ -7766,6 +9642,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7775,6 +9653,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7784,6 +9664,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7793,6 +9675,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7802,6 +9686,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7811,6 +9697,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7820,6 +9708,8 @@ "metric_id": "caseloads.cost_of_living_support_payment", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "cost_of_living_support_payment", @@ -7829,6 +9719,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7838,6 +9730,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7847,6 +9741,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7856,6 +9752,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7865,6 +9763,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7874,6 +9774,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7883,6 +9785,8 @@ "metric_id": "spending.energy_bills_rebate", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "energy_bills_rebate", @@ -7892,6 +9796,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7901,6 +9807,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7910,6 +9818,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7919,6 +9829,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7928,6 +9840,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7937,6 +9851,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7946,6 +9862,8 @@ "metric_id": "caseloads.energy_bills_rebate", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "energy_bills_rebate", @@ -7955,6 +9873,8 @@ "metric_id": "spending.epg_subsidy", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -7964,6 +9884,8 @@ "metric_id": "spending.epg_subsidy", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -7973,6 +9895,8 @@ "metric_id": "spending.epg_subsidy", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -7982,6 +9906,8 @@ "metric_id": "spending.epg_subsidy", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -7991,6 +9917,8 @@ "metric_id": "spending.epg_subsidy", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -8000,6 +9928,8 @@ "metric_id": "spending.epg_subsidy", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -8009,6 +9939,8 @@ "metric_id": "spending.epg_subsidy", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "epg_subsidy", @@ -8018,6 +9950,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8027,6 +9961,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8036,6 +9972,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8045,6 +9983,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8054,6 +9994,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8063,6 +10005,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8072,6 +10016,8 @@ "metric_id": "caseloads.epg_subsidy", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "epg_subsidy", @@ -8081,6 +10027,8 @@ "metric_id": "spending.basic_income", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8090,6 +10038,8 @@ "metric_id": "spending.basic_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8099,6 +10049,8 @@ "metric_id": "spending.basic_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8108,6 +10060,8 @@ "metric_id": "spending.basic_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8117,6 +10071,8 @@ "metric_id": "spending.basic_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8126,6 +10082,8 @@ "metric_id": "spending.basic_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8135,6 +10093,8 @@ "metric_id": "spending.basic_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "basic_income", @@ -8144,6 +10104,8 @@ "metric_id": "caseloads.basic_income", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8153,6 +10115,8 @@ "metric_id": "caseloads.basic_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8162,6 +10126,8 @@ "metric_id": "caseloads.basic_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8171,6 +10137,8 @@ "metric_id": "caseloads.basic_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8180,6 +10148,8 @@ "metric_id": "caseloads.basic_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8189,6 +10159,8 @@ "metric_id": "caseloads.basic_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8198,6 +10170,8 @@ "metric_id": "caseloads.basic_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "basic_income", @@ -8207,6 +10181,8 @@ "metric_id": "spending.disability_basic_income", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8216,6 +10192,8 @@ "metric_id": "spending.disability_basic_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8225,6 +10203,8 @@ "metric_id": "spending.disability_basic_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8234,6 +10214,8 @@ "metric_id": "spending.disability_basic_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8243,6 +10225,8 @@ "metric_id": "spending.disability_basic_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8252,6 +10236,8 @@ "metric_id": "spending.disability_basic_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8261,6 +10247,8 @@ "metric_id": "spending.disability_basic_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "disability_basic_income", @@ -8270,6 +10258,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8279,6 +10269,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8288,6 +10280,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8297,6 +10291,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8306,6 +10302,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8315,6 +10313,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8324,6 +10324,8 @@ "metric_id": "caseloads.disability_basic_income", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "caseload", "partition": "disability_basic_income", @@ -8333,6 +10335,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2025, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8342,6 +10346,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2026, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8351,6 +10357,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2027, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8360,6 +10368,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2028, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8369,6 +10379,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2029, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8378,6 +10390,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2030, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8387,6 +10401,8 @@ "metric_id": "spending.other_public_spending_budget_change", "year": 2031, "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "spending", "partition": "other_public_spending_budget_change", @@ -8395,7 +10411,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2025, - "value": 3130000070.860857, + "value": 3130000159.912464, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8404,7 +10422,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2026, - "value": 3129999934.869108, + "value": 3130000023.6063724, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8413,7 +10433,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2027, - "value": 3129999659.4758515, + "value": 3129999836.909186, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8422,7 +10444,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2028, - "value": 3129999971.286581, + "value": 3129999962.584343, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8431,7 +10455,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2029, - "value": 3129999604.8102255, + "value": 3129999544.0565424, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8440,7 +10466,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2030, - "value": 3129999725.6754894, + "value": 3129999895.007397, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8449,7 +10477,9 @@ { "metric_id": "double_counts.corporate_sdlt", "year": 2031, - "value": 3129999838.862631, + "value": 3129999484.877694, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "double_count", "partition": null, @@ -8458,7 +10488,9 @@ { "metric_id": "model.gov_tax", "year": 2025, - "value": 978966763914.5134, + "value": 969745776128.1699, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8467,7 +10499,9 @@ { "metric_id": "model.gov_tax", "year": 2026, - "value": 1021443831661.053, + "value": 1011784948712.7561, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8476,7 +10510,9 @@ { "metric_id": "model.gov_tax", "year": 2027, - "value": 1060540354380.0239, + "value": 1050610465294.2213, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8485,7 +10521,9 @@ { "metric_id": "model.gov_tax", "year": 2028, - "value": 1096036577748.5464, + "value": 1085614034747.1652, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8494,7 +10532,9 @@ { "metric_id": "model.gov_tax", "year": 2029, - "value": 1133645091567.8396, + "value": 1124265122009.6616, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8503,7 +10543,9 @@ { "metric_id": "model.gov_tax", "year": 2030, - "value": 1172964045336.6704, + "value": 1163453498626.6191, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8512,7 +10554,9 @@ { "metric_id": "model.gov_tax", "year": 2031, - "value": 1217259329604.8296, + "value": 1206982869582.7024, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8521,7 +10565,9 @@ { "metric_id": "model.household_tax", "year": 2025, - "value": 507289555585.61816, + "value": 503977430092.485, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8530,7 +10576,9 @@ { "metric_id": "model.household_tax", "year": 2026, - "value": 534626593895.75964, + "value": 531055850973.52136, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8539,7 +10587,9 @@ { "metric_id": "model.household_tax", "year": 2027, - "value": 560967047377.9646, + "value": 557277774892.158, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8548,7 +10598,9 @@ { "metric_id": "model.household_tax", "year": 2028, - "value": 583794322841.5969, + "value": 579764609886.5848, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8557,7 +10609,9 @@ { "metric_id": "model.household_tax", "year": 2029, - "value": 606736767704.2439, + "value": 603325541929.8473, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8566,7 +10620,9 @@ { "metric_id": "model.household_tax", "year": 2030, - "value": 631883079371.0518, + "value": 628508152120.9532, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8575,7 +10631,9 @@ { "metric_id": "model.household_tax", "year": 2031, - "value": 660238702302.3933, + "value": 656263699478.5581, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8584,7 +10642,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2025, - "value": 499459280987.149, + "value": 496150814427.7457, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8593,7 +10653,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2026, - "value": 526499323434.2302, + "value": 522932377454.86273, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8602,7 +10664,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2027, - "value": 552613929618.1678, + "value": 548928559890.4607, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8611,7 +10675,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2028, - "value": 574737815078.9714, + "value": 570722062751.3651, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8620,7 +10686,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2029, - "value": 597441283752.3987, + "value": 594044368863.5272, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8629,7 +10697,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2030, - "value": 622323704519.8606, + "value": 618963456079.3445, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8638,7 +10708,9 @@ { "metric_id": "model.pre_budget_change_household_tax", "year": 2031, - "value": 651647666882.8048, + "value": 646366448427.6294, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8647,7 +10719,11 @@ { "metric_id": "model.gov_spending", "year": 2025, - "value": 538033639372.52985, + "value": 536882494246.02844, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8656,7 +10732,11 @@ { "metric_id": "model.gov_spending", "year": 2026, - "value": 552208058548.4539, + "value": 551017143116.2849, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8665,7 +10745,11 @@ { "metric_id": "model.gov_spending", "year": 2027, - "value": 559025643057.2411, + "value": 557190195649.4889, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8674,7 +10758,11 @@ { "metric_id": "model.gov_spending", "year": 2028, - "value": 566053472893.2496, + "value": 564067532600.854, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8683,7 +10771,11 @@ { "metric_id": "model.gov_spending", "year": 2029, - "value": 574523316911.5459, + "value": 572364658929.4899, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8692,7 +10784,11 @@ { "metric_id": "model.gov_spending", "year": 2030, - "value": 579305085703.9962, + "value": 577022180786.2144, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8701,7 +10797,11 @@ { "metric_id": "model.gov_spending", "year": 2031, - "value": 589946933468.4061, + "value": 586109747489.8334, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8710,7 +10810,11 @@ { "metric_id": "model.household_benefits", "year": 2025, - "value": 538389684305.1851, + "value": 536882494246.02844, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8719,7 +10823,11 @@ { "metric_id": "model.household_benefits", "year": 2026, - "value": 552578598164.1414, + "value": 551017143116.2849, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8728,7 +10836,11 @@ { "metric_id": "model.household_benefits", "year": 2027, - "value": 559406220932.2922, + "value": 557190195649.4889, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8737,7 +10849,11 @@ { "metric_id": "model.household_benefits", "year": 2028, - "value": 566443704502.9469, + "value": 564067532600.854, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8746,7 +10862,11 @@ { "metric_id": "model.household_benefits", "year": 2029, - "value": 574919476375.7018, + "value": 572364658929.4899, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8755,7 +10875,11 @@ { "metric_id": "model.household_benefits", "year": 2030, - "value": 579709866219.54, + "value": 577022180786.2144, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8764,7 +10888,11 @@ { "metric_id": "model.household_benefits", "year": 2031, - "value": 590354941802.2732, + "value": 586109747489.8334, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -8773,7 +10901,9 @@ { "metric_id": "balance.receipts", "year": 2025, - "value": 975836764403.4154, + "value": 966615777128.9729, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8782,7 +10912,9 @@ { "metric_id": "balance.receipts", "year": 2026, - "value": 1018313832613.0851, + "value": 1008654948197.3245, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8791,7 +10923,9 @@ { "metric_id": "balance.receipts", "year": 2027, - "value": 1057410354079.6709, + "value": 1047480466355.038, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8800,7 +10934,9 @@ { "metric_id": "balance.receipts", "year": 2028, - "value": 1092906577896.0876, + "value": 1082484034898.3741, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8809,7 +10945,9 @@ { "metric_id": "balance.receipts", "year": 2029, - "value": 1130515092103.1807, + "value": 1121135120971.1062, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8818,7 +10956,9 @@ { "metric_id": "balance.receipts", "year": 2030, - "value": 1169834045361.7195, + "value": 1160323499762.114, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8827,7 +10967,9 @@ { "metric_id": "balance.receipts", "year": 2031, - "value": 1214129326993.4458, + "value": 1203852872416.6934, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8836,7 +10978,11 @@ { "metric_id": "balance.spending", "year": 2025, - "value": 538033639676.7738, + "value": 536882494246.02844, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8845,7 +10991,11 @@ { "metric_id": "balance.spending", "year": 2026, - "value": 552208059037.354, + "value": 551017143116.2849, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8854,7 +11004,11 @@ { "metric_id": "balance.spending", "year": 2027, - "value": 559025643307.1086, + "value": 557190195649.4889, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8863,7 +11017,11 @@ { "metric_id": "balance.spending", "year": 2028, - "value": 566053472435.6144, + "value": 564067532600.854, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8872,7 +11030,11 @@ { "metric_id": "balance.spending", "year": 2029, - "value": 574523317043.8452, + "value": 572364658929.4899, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8881,7 +11043,11 @@ { "metric_id": "balance.spending", "year": 2030, - "value": 579305085287.3065, + "value": 577022180786.2144, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8890,7 +11056,11 @@ { "metric_id": "balance.spending", "year": 2031, - "value": 589946932722.4471, + "value": 586109747489.8334, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8899,7 +11069,11 @@ { "metric_id": "balance.balance", "year": 2025, - "value": 437803124726.64154, + "value": 429733282882.94446, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8908,7 +11082,11 @@ { "metric_id": "balance.balance", "year": 2026, - "value": 466105773575.731, + "value": 457637805081.0396, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8917,7 +11095,11 @@ { "metric_id": "balance.balance", "year": 2027, - "value": 498384710772.5623, + "value": 490290270705.5491, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8926,7 +11108,11 @@ { "metric_id": "balance.balance", "year": 2028, - "value": 526853105460.47296, + "value": 518416502297.52026, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8935,7 +11121,11 @@ { "metric_id": "balance.balance", "year": 2029, - "value": 555991775059.3357, + "value": 548770462041.61646, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8944,7 +11134,11 @@ { "metric_id": "balance.balance", "year": 2030, - "value": 590528960074.4128, + "value": 583301318975.8997, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8953,7 +11147,11 @@ { "metric_id": "balance.balance", "year": 2031, - "value": 624182394270.9988, + "value": 617743124926.8599, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "balance", "partition": null, @@ -8962,7 +11160,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2025, - "value": 551697696.1319505, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8971,7 +11171,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2026, - "value": 573788727.0823206, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8980,7 +11182,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2027, - "value": 587932937.3870641, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8989,7 +11193,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2028, - "value": 601425544.379385, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -8998,7 +11204,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2029, - "value": 612524542.784497, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -9007,7 +11215,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2030, - "value": 626466307.2763597, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -9016,7 +11226,9 @@ { "metric_id": "balance.household_only_benefits", "year": 2031, - "value": 635100657.4934409, + "value": 0.0, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "balance", "partition": null, @@ -9025,7 +11237,11 @@ { "metric_id": "model.gov_balance", "year": 2025, - "value": 440933124873.35913, + "value": 432863283042.85693, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9034,7 +11250,11 @@ { "metric_id": "model.gov_balance", "year": 2026, - "value": 469235773431.7386, + "value": 460767805104.64594, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9043,7 +11263,11 @@ { "metric_id": "model.gov_balance", "year": 2027, - "value": 501514711424.7265, + "value": 493420270542.4583, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9052,7 +11276,11 @@ { "metric_id": "model.gov_balance", "year": 2028, - "value": 529983106859.2174, + "value": 521546502260.1046, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9061,7 +11289,11 @@ { "metric_id": "model.gov_balance", "year": 2029, - "value": 559121775135.3358, + "value": 551900461585.6729, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9070,7 +11302,11 @@ { "metric_id": "model.gov_balance", "year": 2030, - "value": 593658959431.4342, + "value": 586431318870.907, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9079,7 +11315,11 @@ { "metric_id": "model.gov_balance", "year": 2031, - "value": 627312396103.4205, + "value": 620873124411.7375, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "model_total", "partition": null, @@ -9088,7 +11328,9 @@ { "metric_id": "household.household_market_income", "year": 2025, - "value": 1671225658932.162, + "value": 1657055494245.5037, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9097,7 +11339,9 @@ { "metric_id": "household.household_market_income", "year": 2026, - "value": 1733933903918.711, + "value": 1719245701089.3281, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9106,7 +11350,9 @@ { "metric_id": "household.household_market_income", "year": 2027, - "value": 1786792811393.0068, + "value": 1771683231648.2764, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9115,7 +11361,9 @@ { "metric_id": "household.household_market_income", "year": 2028, - "value": 1837490253867.1548, + "value": 1821985672731.2063, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9124,7 +11372,9 @@ { "metric_id": "household.household_market_income", "year": 2029, - "value": 1899443273442.607, + "value": 1887412879505.8608, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9133,7 +11383,9 @@ { "metric_id": "household.household_market_income", "year": 2030, - "value": 1958070419692.7725, + "value": 1945587770944.968, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9142,7 +11394,9 @@ { "metric_id": "household.household_market_income", "year": 2031, - "value": 2030307938225.9048, + "value": 2017368706417.267, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9151,7 +11405,11 @@ { "metric_id": "household.household_net_income", "year": 2025, - "value": 1662845456756.1794, + "value": 1650695092699.1787, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9160,7 +11418,11 @@ { "metric_id": "household.household_net_income", "year": 2026, - "value": 1710908123095.9045, + "value": 1698452218971.148, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9169,7 +11431,11 @@ { "metric_id": "household.household_net_income", "year": 2027, - "value": 1743115474011.6213, + "value": 1729708349505.7048, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9178,7 +11444,11 @@ { "metric_id": "household.household_net_income", "year": 2028, - "value": 1776966674392.025, + "value": 1763350595681.0042, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9187,7 +11457,11 @@ { "metric_id": "household.household_net_income", "year": 2029, - "value": 1812648751541.618, + "value": 1797840584674.6392, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9196,7 +11470,11 @@ { "metric_id": "household.household_net_income", "year": 2030, - "value": 1849604189724.0015, + "value": 1834164055321.444, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9205,7 +11483,11 @@ { "metric_id": "household.household_net_income", "year": 2031, - "value": 1901814852667.9023, + "value": 1884778958157.876, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9214,7 +11496,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2025, - "value": 1477497599801.4927, + "value": 1468438454639.119, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9223,7 +11507,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2026, - "value": 1524365447451.7742, + "value": 1515139365275.6355, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9232,7 +11518,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2027, - "value": 1557424708805.0208, + "value": 1547358223256.206, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9241,7 +11529,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2028, - "value": 1590631579327.68, + "value": 1580410096999.486, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9250,7 +11540,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2029, - "value": 1624770529416.2014, + "value": 1613429021347.468, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9259,7 +11551,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2030, - "value": 1659800503787.3513, + "value": 1647838190515.2617, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9268,7 +11562,9 @@ { "metric_id": "household.hbai_household_net_income", "year": 2031, - "value": 1706265955643.35, + "value": 1697900004504.373, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "household_total", "partition": null, @@ -9277,7 +11573,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2025, - "value": 0.15790499791115192, + "value": 0.158961068989031, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9286,7 +11584,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2026, - "value": 0.15015054717546153, + "value": 0.149417048095037, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9295,7 +11595,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2027, - "value": 0.1549582355031067, + "value": 0.1551359605887892, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9304,7 +11606,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2028, - "value": 0.15586605016301394, + "value": 0.15590985583220196, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9313,7 +11617,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2029, - "value": 0.15679856209322698, + "value": 0.15717923007921353, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9322,7 +11628,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2030, - "value": 0.15996963062767566, + "value": 0.1610142002033216, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9331,7 +11639,9 @@ { "metric_id": "poverty.absolute_bhc.all", "year": 2031, - "value": 0.15916276272552643, + "value": 0.1599545027297433, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9340,7 +11650,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2025, - "value": 11008006.535612345, + "value": 11050012.62085329, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9349,7 +11661,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2026, - "value": 10507198.299471915, + "value": 10426039.302517518, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9358,7 +11672,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2027, - "value": 10883751.14921306, + "value": 10865147.24560164, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9367,7 +11683,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2028, - "value": 10991302.525305092, + "value": 10963024.831684813, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9376,7 +11694,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2029, - "value": 11105712.280909285, + "value": 11100912.898740932, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9385,7 +11705,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2030, - "value": 11381299.168434069, + "value": 11422934.10709335, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9394,7 +11716,9 @@ { "metric_id": "poverty_count.absolute_bhc.all", "year": 2031, - "value": 11372607.559264764, + "value": 11396550.62013276, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9403,7 +11727,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2025, - "value": 0.23024237894583952, + "value": 0.2372510378251411, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9412,7 +11738,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2026, - "value": 0.20587063573611564, + "value": 0.20852661548421755, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9421,7 +11749,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2027, - "value": 0.21312205523439626, + "value": 0.21671681039197135, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9430,7 +11760,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2028, - "value": 0.21148974274821794, + "value": 0.21457714000706526, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9439,7 +11771,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2029, - "value": 0.2129381736428246, + "value": 0.21642597560772944, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9448,7 +11782,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2030, - "value": 0.2171366623415043, + "value": 0.2217104975392767, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9457,7 +11793,9 @@ { "metric_id": "poverty.absolute_bhc.children", "year": 2031, - "value": 0.2168645562087503, + "value": 0.22078118293404653, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9466,7 +11804,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2025, - "value": 3458586.670649603, + "value": 3559375.146835327, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9475,7 +11815,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2026, - "value": 3104238.0042080134, + "value": 3140323.1707639247, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9484,7 +11826,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2027, - "value": 3225469.4537050426, + "value": 3275739.6749524027, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9493,7 +11837,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2028, - "value": 3213568.3187645674, + "value": 3256371.3432342857, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9502,7 +11848,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2029, - "value": 3249813.6879644096, + "value": 3298880.366600305, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9511,7 +11859,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2030, - "value": 3328802.6769966334, + "value": 3394637.414582446, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9520,7 +11870,9 @@ { "metric_id": "poverty_count.absolute_bhc.children", "year": 2031, - "value": 3338933.418882489, + "value": 3394944.311651394, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9529,7 +11881,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2025, - "value": 0.13730515589263637, + "value": 0.13767748825105397, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9538,7 +11892,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2026, - "value": 0.13444889083444914, + "value": 0.13372106552488075, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9547,7 +11903,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2027, - "value": 0.13964335698946462, + "value": 0.13989204520959575, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9556,7 +11914,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2028, - "value": 0.14095635404241666, + "value": 0.14124164361087443, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9565,7 +11925,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2029, - "value": 0.14196947141222863, + "value": 0.14248707378049913, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9574,7 +11936,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2030, - "value": 0.1453637344339517, + "value": 0.14658630153368837, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9583,7 +11947,9 @@ { "metric_id": "poverty.absolute_bhc.working_age_adults", "year": 2031, - "value": 0.1424988521749652, + "value": 0.14525417694146392, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9592,7 +11958,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2025, - "value": 5861346.487035349, + "value": 5863952.6221417785, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9601,7 +11969,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2026, - "value": 5780330.343414247, + "value": 5736660.92677407, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9610,7 +11980,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2027, - "value": 6067671.953066379, + "value": 6072487.460049972, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9619,7 +11991,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2028, - "value": 6169748.167899072, + "value": 6178484.984416798, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9628,7 +12002,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2029, - "value": 6241435.053964257, + "value": 6260390.306578115, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9637,7 +12013,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2030, - "value": 6419415.955847114, + "value": 6469478.6622083485, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9646,7 +12024,9 @@ { "metric_id": "poverty_count.absolute_bhc.working_age_adults", "year": 2031, - "value": 6319971.187485427, + "value": 6438252.252475888, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9655,7 +12035,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2025, - "value": 0.14063908804109557, + "value": 0.13647330334302774, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9664,7 +12046,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2026, - "value": 0.13628217188144012, + "value": 0.13107228723131284, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9673,7 +12057,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2027, - "value": 0.1365202033742876, + "value": 0.13176164489560352, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9682,7 +12068,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2028, - "value": 0.1391945511558675, + "value": 0.13408989504529978, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9691,7 +12079,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2029, - "value": 0.1391430413120495, + "value": 0.13467956142673865, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9700,7 +12090,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2030, - "value": 0.14011702215368302, + "value": 0.13556998964969896, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9709,7 +12101,9 @@ { "metric_id": "poverty.absolute_bhc.pensioners", "year": 2031, - "value": 0.14640454331430913, + "value": 0.13538234298373342, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9718,7 +12112,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2025, - "value": 1688073.3779273927, + "value": 1626684.8518761843, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9727,7 +12123,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2026, - "value": 1622629.9518496543, + "value": 1549055.204979524, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9736,7 +12134,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2027, - "value": 1590609.7424416393, + "value": 1516920.1105992645, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9745,7 +12145,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2028, - "value": 1607986.0386414528, + "value": 1528168.5040337294, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9754,7 +12156,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2029, - "value": 1614463.5389806181, + "value": 1541642.2255625129, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9763,7 +12167,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2030, - "value": 1633080.5355903208, + "value": 1558818.0303025544, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9772,7 +12178,9 @@ { "metric_id": "poverty_count.absolute_bhc.pensioners", "year": 2031, - "value": 1713702.9528968483, + "value": 1563354.056005478, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9781,7 +12189,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2025, - "value": 0.2084922594723321, + "value": 0.20913249144654364, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9790,7 +12200,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2026, - "value": 0.2033983080431449, + "value": 0.20368321654160576, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9799,7 +12211,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2027, - "value": 0.2085258023862174, + "value": 0.2099396779334123, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9808,7 +12222,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2028, - "value": 0.21083623822359046, + "value": 0.21265695145642433, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9817,7 +12233,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2029, - "value": 0.21373904608988534, + "value": 0.2154006487190125, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9826,7 +12244,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2030, - "value": 0.2213187707909106, + "value": 0.22356054596434546, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9835,7 +12255,9 @@ { "metric_id": "poverty.absolute_ahc.all", "year": 2031, - "value": 0.2195671141063277, + "value": 0.223770468364186, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9844,7 +12266,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2025, - "value": 14534588.42504394, + "value": 14537626.631551296, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9853,7 +12277,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2026, - "value": 14233357.097853199, + "value": 14212630.00441049, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9862,7 +12288,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2027, - "value": 14646158.908514857, + "value": 14703396.329152241, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9871,7 +12299,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2028, - "value": 14867669.227449715, + "value": 14953278.142699987, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9880,7 +12310,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2029, - "value": 15138686.971242383, + "value": 15212848.660455883, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9889,7 +12321,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2030, - "value": 15746083.378938913, + "value": 15860199.797730953, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9898,7 +12332,9 @@ { "metric_id": "poverty_count.absolute_ahc.all", "year": 2031, - "value": 15688660.958704859, + "value": 15943355.307177946, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9907,7 +12343,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2025, - "value": 0.31720685844300495, + "value": 0.3176647822027365, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9916,7 +12354,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2026, - "value": 0.30038158814530413, + "value": 0.29976792277040976, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9925,7 +12365,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2027, - "value": 0.3065600330656107, + "value": 0.3075246154957757, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9934,7 +12376,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2028, - "value": 0.3089024392477794, + "value": 0.31040394928891696, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9943,7 +12387,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2029, - "value": 0.3131465359163617, + "value": 0.31404388184177656, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9952,7 +12398,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2030, - "value": 0.32764715169339104, + "value": 0.32925032071219956, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9961,7 +12409,9 @@ { "metric_id": "poverty.absolute_ahc.children", "year": 2031, - "value": 0.32587820140005935, + "value": 0.3295364201437651, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -9970,7 +12420,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2025, - "value": 4764923.892259136, + "value": 4765787.92304638, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9979,7 +12431,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2026, - "value": 4529329.490584716, + "value": 4514378.90334405, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9988,7 +12442,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2027, - "value": 4639595.002462, + "value": 4648326.921118781, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -9997,7 +12453,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2028, - "value": 4693745.802781373, + "value": 4710616.076148167, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10006,7 +12464,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2029, - "value": 4779170.786289513, + "value": 4786824.655172229, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10015,7 +12475,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2030, - "value": 5022978.173772946, + "value": 5041193.222052559, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10024,7 +12486,9 @@ { "metric_id": "poverty_count.absolute_ahc.children", "year": 2031, - "value": 5017351.0884489715, + "value": 5067269.683862701, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10033,7 +12497,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2025, - "value": 0.19375179335562778, + "value": 0.19431403398519395, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10042,7 +12508,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2026, - "value": 0.1915301826384273, + "value": 0.19237286661348602, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10051,7 +12519,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2027, - "value": 0.19779351466886932, + "value": 0.1997992793069061, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10060,7 +12530,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2028, - "value": 0.19985745387449372, + "value": 0.20238185535311953, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10069,7 +12541,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2029, - "value": 0.20299186364561206, + "value": 0.20530273277647584, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10078,7 +12552,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2030, - "value": 0.21009860237041597, + "value": 0.2131128866367783, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10087,7 +12563,9 @@ { "metric_id": "poverty.absolute_ahc.working_age_adults", "year": 2031, - "value": 0.205233615867211, + "value": 0.21322897588420822, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10096,7 +12574,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2025, - "value": 8270966.854513526, + "value": 8276213.515957296, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10105,7 +12585,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2026, - "value": 8234413.236980796, + "value": 8252835.130660608, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10114,7 +12596,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2027, - "value": 8594366.300899416, + "value": 8672963.61491029, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10123,7 +12607,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2028, - "value": 8747886.310340255, + "value": 8853006.963460386, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10132,7 +12618,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2029, - "value": 8924175.886719018, + "value": 9020293.589352593, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10141,7 +12629,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2030, - "value": 9278176.056839228, + "value": 9405580.591863185, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10150,7 +12640,9 @@ { "metric_id": "poverty_count.absolute_ahc.working_age_adults", "year": 2031, - "value": 9102322.714793772, + "value": 9451170.101861268, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10159,7 +12651,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2025, - "value": 0.1248615596202156, + "value": 0.12547784554248842, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10168,7 +12662,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2026, - "value": 0.12343063061462466, + "value": 0.12230292156970884, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10177,7 +12673,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2027, - "value": 0.12120729498631465, + "value": 0.12005149872375881, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10186,7 +12684,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2028, - "value": 0.12344422855077232, + "value": 0.12193596873043704, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10195,7 +12695,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2029, - "value": 0.12370524920005636, + "value": 0.12280615616424372, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10204,7 +12706,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2030, - "value": 0.12397378149719714, + "value": 0.1229252820223357, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10213,7 +12717,9 @@ { "metric_id": "poverty.absolute_ahc.pensioners", "year": 2031, - "value": 0.13404122784123754, + "value": 0.12339393057336441, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10222,7 +12728,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2025, - "value": 1498697.6782712787, + "value": 1495625.1925476193, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10231,7 +12739,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2026, - "value": 1469614.3702876866, + "value": 1445415.970405832, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10240,7 +12750,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2027, - "value": 1412197.6051534414, + "value": 1382105.7931231707, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10249,7 +12761,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2028, - "value": 1426037.1143280864, + "value": 1389655.1030914336, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10258,7 +12772,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2029, - "value": 1435340.2982338518, + "value": 1405730.415931061, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10267,7 +12783,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2030, - "value": 1444929.1483267397, + "value": 1413425.983815208, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10276,7 +12794,9 @@ { "metric_id": "poverty_count.absolute_ahc.pensioners", "year": 2031, - "value": 1568987.155462116, + "value": 1424915.5214539766, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10285,7 +12805,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2025, - "value": 0.17062007668867718, + "value": 0.16996315965340555, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10294,7 +12816,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2026, - "value": 0.1647051950949729, + "value": 0.1641757039106901, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10303,7 +12827,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2027, - "value": 0.1644876206126917, + "value": 0.1658306356282735, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10312,7 +12838,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2028, - "value": 0.16684150829295824, + "value": 0.16675706960047298, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10321,7 +12849,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2029, - "value": 0.1684367435335271, + "value": 0.16818506457390087, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10330,7 +12860,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2030, - "value": 0.1631533914075491, + "value": 0.16603686894151898, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10339,7 +12871,9 @@ { "metric_id": "poverty.relative_bhc.all", "year": 2031, - "value": 0.169943728112801, + "value": 0.16939429823668872, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10348,7 +12882,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2025, - "value": 11894410.842856497, + "value": 11814811.457891181, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10357,7 +12893,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2026, - "value": 11525699.895011187, + "value": 11455870.419837251, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10366,7 +12904,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2027, - "value": 11553063.469406977, + "value": 11614162.616421178, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10375,7 +12915,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2028, - "value": 11765265.684914708, + "value": 11725762.204902157, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10384,7 +12926,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2029, - "value": 11930020.187968686, + "value": 11878209.046851024, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10393,7 +12937,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2030, - "value": 11607812.999679968, + "value": 11779260.530264333, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10402,7 +12948,9 @@ { "metric_id": "poverty_count.relative_bhc.all", "year": 2031, - "value": 12142936.53797774, + "value": 12069123.79252024, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10411,7 +12959,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2025, - "value": 0.25579913589553366, + "value": 0.2576548985094404, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10420,7 +12970,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2026, - "value": 0.23345621726435167, + "value": 0.2349385317072657, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10429,7 +12981,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2027, - "value": 0.22912843225646004, + "value": 0.23473844147435505, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10438,7 +12992,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2028, - "value": 0.23084457664090657, + "value": 0.23360704801218143, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10447,7 +13003,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2029, - "value": 0.23197251050977175, + "value": 0.23495351453647773, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10456,7 +13014,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2030, - "value": 0.2220824501863291, + "value": 0.23174222961978033, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10465,7 +13025,9 @@ { "metric_id": "poverty.relative_bhc.children", "year": 2031, - "value": 0.23445025294767866, + "value": 0.23914597293246234, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10474,7 +13036,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2025, - "value": 3842487.581228867, + "value": 3865485.4816306233, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10483,7 +13047,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2026, - "value": 3520189.556705728, + "value": 3538075.5262937695, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10492,7 +13058,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2027, - "value": 3467715.9921610057, + "value": 3548142.041142389, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10501,7 +13069,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2028, - "value": 3507663.3429688513, + "value": 3545164.6745751947, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10510,7 +13080,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2029, - "value": 3540311.3823575675, + "value": 3581287.015071422, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10519,7 +13091,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2030, - "value": 3404623.8287089914, + "value": 3548234.530783549, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10528,7 +13102,9 @@ { "metric_id": "poverty_count.relative_bhc.children", "year": 2031, - "value": 3609689.837369889, + "value": 3677339.0271395296, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10537,7 +13113,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2025, - "value": 0.14589881032453236, + "value": 0.14512498963363443, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10546,7 +13124,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2026, - "value": 0.14416151943670144, + "value": 0.14359366405754964, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10555,7 +13135,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2027, - "value": 0.1465298878616869, + "value": 0.1475713897931732, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10564,7 +13146,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2028, - "value": 0.14949165839067746, + "value": 0.1494918942688663, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10573,7 +13157,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2029, - "value": 0.15186122947304564, + "value": 0.1516084128453753, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10582,7 +13168,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2030, - "value": 0.14784727703111472, + "value": 0.1503840561165627, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10591,7 +13179,9 @@ { "metric_id": "poverty.relative_bhc.working_age_adults", "year": 2031, - "value": 0.15069757220037083, + "value": 0.15254973464975796, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10600,7 +13190,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2025, - "value": 6228196.412573293, + "value": 6181156.224673748, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10609,7 +13201,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2026, - "value": 6197903.1584480405, + "value": 6160197.413159236, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10618,7 +13212,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2027, - "value": 6366900.008937746, + "value": 6405835.3899862915, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10627,7 +13223,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2028, - "value": 6543343.801262975, + "value": 6539384.56406565, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10636,7 +13234,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2029, - "value": 6676308.586224228, + "value": 6661150.467831269, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10645,7 +13245,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2030, - "value": 6529091.818519026, + "value": 6637089.76898399, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10654,7 +13256,9 @@ { "metric_id": "poverty_count.relative_bhc.working_age_adults", "year": 2031, - "value": 6683592.883688301, + "value": 6761620.8594070375, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10663,7 +13267,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2025, - "value": 0.15194083636457575, + "value": 0.14834340320557673, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10672,7 +13278,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2026, - "value": 0.15181812223954255, + "value": 0.14871795468967855, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10681,7 +13289,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2027, - "value": 0.1474923682419708, + "value": 0.14420583478126237, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10690,7 +13300,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2028, - "value": 0.14839398004790294, + "value": 0.14400903683858882, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10699,7 +13311,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2029, - "value": 0.1476699298274378, + "value": 0.14290280401894626, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10708,7 +13322,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2030, - "value": 0.14363623269573814, + "value": 0.1386242101836538, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10717,7 +13333,9 @@ { "metric_id": "poverty.relative_bhc.pensioners", "year": 2031, - "value": 0.1580190556933559, + "value": 0.14116789999709253, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10726,7 +13344,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2025, - "value": 1823726.8490543365, + "value": 1768169.7515868098, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10735,7 +13355,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2026, - "value": 1807607.179857418, + "value": 1757597.4803842455, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10744,7 +13366,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2027, - "value": 1718447.4683082253, + "value": 1660185.1852924973, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10753,7 +13377,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2028, - "value": 1714258.540682882, + "value": 1641212.9662613124, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10762,7 +13388,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2029, - "value": 1713400.2193868905, + "value": 1635771.5639483333, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10771,7 +13399,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2030, - "value": 1674097.3524519503, + "value": 1593936.230496794, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10780,7 +13410,9 @@ { "metric_id": "poverty_count.relative_bhc.pensioners", "year": 2031, - "value": 1849653.8169195503, + "value": 1630163.9059736729, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10789,7 +13421,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2025, - "value": 0.21330212332422502, + "value": 0.21456512996755925, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10798,7 +13432,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2026, - "value": 0.2105464759711648, + "value": 0.2108568153025047, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10807,7 +13443,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2027, - "value": 0.21119415618195903, + "value": 0.21280995156860974, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10816,7 +13454,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2028, - "value": 0.2124423499774284, + "value": 0.2141820445617756, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10825,7 +13465,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2029, - "value": 0.2140000786845929, + "value": 0.2155949209344568, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10834,7 +13476,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2030, - "value": 0.2204001169903213, + "value": 0.21996997492343381, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10843,7 +13487,9 @@ { "metric_id": "poverty.relative_ahc.all", "year": 2031, - "value": 0.2194456696036841, + "value": 0.2227237561956704, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10852,7 +13498,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2025, - "value": 14869897.714917302, + "value": 14915270.822066262, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10861,7 +13509,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2026, - "value": 14733569.846394584, + "value": 14713190.171909258, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10870,7 +13520,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2027, - "value": 14833575.205535918, + "value": 14904419.64807345, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10879,7 +13531,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2028, - "value": 14980928.401961535, + "value": 15060517.248883188, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10888,7 +13542,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2029, - "value": 15157175.360766247, + "value": 15226569.296071708, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10897,7 +13553,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2030, - "value": 15680724.262363464, + "value": 15605471.6038488, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10906,7 +13564,9 @@ { "metric_id": "poverty_count.relative_ahc.all", "year": 2031, - "value": 15679983.42229405, + "value": 15868778.424316749, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10915,7 +13575,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2025, - "value": 0.3248406624741081, + "value": 0.32573092950864546, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10924,7 +13586,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2026, - "value": 0.3131278902007049, + "value": 0.3129215499802995, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10933,7 +13597,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2027, - "value": 0.30979319184398263, + "value": 0.31080197766773193, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10942,7 +13608,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2028, - "value": 0.3102686929501225, + "value": 0.3112443481181635, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10951,7 +13619,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2029, - "value": 0.3134903767691043, + "value": 0.31435135617461696, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10960,7 +13630,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2030, - "value": 0.3252235647703323, + "value": 0.32341397660487087, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10969,7 +13641,9 @@ { "metric_id": "poverty.relative_ahc.children", "year": 2031, - "value": 0.3257920669222945, + "value": 0.32841073439755847, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -10978,7 +13652,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2025, - "value": 4879595.105218306, + "value": 4886800.85733974, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10987,7 +13663,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2026, - "value": 4721525.697256014, + "value": 4712466.999728724, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -10996,7 +13674,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2027, - "value": 4688526.845143169, + "value": 4697865.234627768, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11005,7 +13685,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2028, - "value": 4714505.909423858, + "value": 4723369.767731324, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11014,7 +13696,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2029, - "value": 4784418.406716645, + "value": 4791511.343251109, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11023,7 +13707,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2030, - "value": 4985823.496389523, + "value": 4951832.22069715, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11032,7 +13718,9 @@ { "metric_id": "poverty_count.relative_ahc.children", "year": 2031, - "value": 5016024.927589148, + "value": 5049960.054618016, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11041,7 +13729,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2025, - "value": 0.19713460206621386, + "value": 0.19817681755370278, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11050,7 +13740,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2026, - "value": 0.19751408902821108, + "value": 0.19800752646020686, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11059,7 +13751,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2027, - "value": 0.19980334977328973, + "value": 0.2018228258946827, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11068,7 +13762,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2028, - "value": 0.20092494834158509, + "value": 0.20314734474442595, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11077,7 +13773,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2029, - "value": 0.20323204364702235, + "value": 0.2054474903629614, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11086,7 +13784,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2030, - "value": 0.2095568415310035, + "value": 0.21027795561183132, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11095,7 +13795,9 @@ { "metric_id": "poverty.relative_ahc.working_age_adults", "year": 2031, - "value": 0.2051403722499053, + "value": 0.2126635701601802, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11104,7 +13806,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2025, - "value": 8415373.769339144, + "value": 8440736.998503834, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11113,7 +13817,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2026, - "value": 8491677.952683121, + "value": 8494563.18488653, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11122,7 +13828,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2027, - "value": 8681696.055470556, + "value": 8760802.499964043, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11131,7 +13839,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2028, - "value": 8794611.213785172, + "value": 8886492.58844325, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11140,7 +13850,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2029, - "value": 8934734.972874314, + "value": 9026653.738152012, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11149,7 +13861,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2030, - "value": 9254251.326298147, + "value": 9280462.995042503, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11158,7 +13872,9 @@ { "metric_id": "poverty_count.relative_ahc.working_age_adults", "year": 2031, - "value": 9098187.264116138, + "value": 9426109.034751624, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11167,7 +13883,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2025, - "value": 0.1312126349091424, + "value": 0.133205373171771, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11176,7 +13894,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2026, - "value": 0.12769319774473814, + "value": 0.12744273660248256, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11185,7 +13905,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2027, - "value": 0.12559784398751747, + "value": 0.125579883146286, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11194,7 +13916,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2028, - "value": 0.12740664745148308, + "value": 0.12728842519447406, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11203,7 +13927,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2029, - "value": 0.12393637087688392, + "value": 0.12303974216451169, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11212,7 +13938,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2030, - "value": 0.1236065858698001, + "value": 0.119424785385011, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11221,7 +13949,9 @@ { "metric_id": "poverty.relative_ahc.pensioners", "year": 2031, - "value": 0.13376648593709345, + "value": 0.12060495966103543, + "suppressed": false, + "excludes": [], "unit": "rate", "kind": "poverty", "partition": null, @@ -11230,7 +13960,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2025, - "value": 1574928.8403598517, + "value": 1587732.9662226886, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11239,7 +13971,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2026, - "value": 1520366.1964554489, + "value": 1506159.9872940034, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11248,7 +13982,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2027, - "value": 1463352.3049221933, + "value": 1445751.9134816378, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11257,7 +13993,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2028, - "value": 1471811.2787525058, + "value": 1450654.8927086145, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11266,7 +14004,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2029, - "value": 1438021.9811752886, + "value": 1408404.2146685869, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11275,7 +14015,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2030, - "value": 1440649.439675793, + "value": 1373176.3881091475, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11284,7 +14026,9 @@ { "metric_id": "poverty_count.relative_ahc.pensioners", "year": 2031, - "value": 1565771.230588764, + "value": 1392709.3349471092, + "suppressed": false, + "excludes": [], "unit": "count", "kind": "poverty_count", "partition": null, @@ -11293,7 +14037,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2025, - "value": 0.31169660677586286, + "value": 0.3077487914075191, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11302,7 +14048,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2026, - "value": 0.3094015441232131, + "value": 0.3054631654461407, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11311,7 +14059,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2027, - "value": 0.3105687571293162, + "value": 0.3069197698442653, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11320,7 +14070,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2028, - "value": 0.3112819865588672, + "value": 0.3077757166797609, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11329,7 +14081,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2029, - "value": 0.31188628846609523, + "value": 0.3082329014779346, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11338,7 +14092,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2030, - "value": 0.3137188460113969, + "value": 0.3099891970371004, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11347,7 +14103,9 @@ { "metric_id": "inequality.gini_equiv_hbai_bhc", "year": 2031, - "value": 0.3126616094414172, + "value": 0.3110144570254967, + "suppressed": false, + "excludes": [], "unit": "index", "kind": "inequality", "partition": null, @@ -11356,7 +14114,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2025, - "value": 18262.585926938675, + "value": 17676.754667048575, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11365,7 +14127,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2026, - "value": 18565.573452097553, + "value": 17959.072766481713, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11374,7 +14140,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2027, - "value": 18587.9409768735, + "value": 17890.694840247495, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11383,7 +14153,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2028, - "value": 18712.32311277851, + "value": 17941.1511777277, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11392,7 +14166,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2029, - "value": 19079.33376464379, + "value": 18267.238369432558, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11401,7 +14179,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2030, - "value": 19168.884477875916, + "value": 18350.30992490091, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11410,7 +14192,11 @@ { "metric_id": "deciles.mean_income.1", "year": 2031, - "value": 19487.832363597438, + "value": 18550.46356681594, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11419,7 +14205,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2025, - "value": 28212.59715391376, + "value": 28179.055803438234, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11428,7 +14218,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2026, - "value": 29005.91280891695, + "value": 28837.04533117914, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11437,7 +14231,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2027, - "value": 28622.542707465953, + "value": 28424.564554846565, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11446,7 +14244,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2028, - "value": 28878.39944749893, + "value": 28883.45566735774, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11455,7 +14257,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2029, - "value": 29211.074914268487, + "value": 29061.95653619478, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11464,7 +14270,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2030, - "value": 29528.185297717675, + "value": 29759.483201387062, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11473,7 +14283,11 @@ { "metric_id": "deciles.mean_income.2", "year": 2031, - "value": 30103.165466505827, + "value": 30221.58748982937, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11482,7 +14296,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2025, - "value": 34413.19157264763, + "value": 34530.61843120579, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11491,7 +14309,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2026, - "value": 35673.03127903994, + "value": 35774.3120618477, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11500,7 +14322,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2027, - "value": 36287.079062372846, + "value": 36424.80493358469, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11509,7 +14335,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2028, - "value": 36891.915260352755, + "value": 36772.53572338885, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11518,7 +14348,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2029, - "value": 37204.02706843331, + "value": 37156.5637359853, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11527,7 +14361,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2030, - "value": 37191.52316634799, + "value": 36685.78880542181, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11536,7 +14374,11 @@ { "metric_id": "deciles.mean_income.3", "year": 2031, - "value": 37443.87462441918, + "value": 37377.12158903903, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11545,7 +14387,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2025, - "value": 37642.82250138588, + "value": 38934.091210418585, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11554,7 +14400,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2026, - "value": 39339.82023962752, + "value": 40539.44017480899, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11563,7 +14413,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2027, - "value": 40283.78733924495, + "value": 41468.195696983545, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11572,7 +14426,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2028, - "value": 41009.69588636075, + "value": 42373.92850208078, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11581,7 +14439,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2029, - "value": 41559.21709716546, + "value": 42841.52793419517, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11590,7 +14452,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2030, - "value": 42169.73199175639, + "value": 43336.4971038302, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11599,7 +14465,11 @@ { "metric_id": "deciles.mean_income.4", "year": 2031, - "value": 43752.63830672907, + "value": 44285.75677905547, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11608,7 +14478,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2025, - "value": 45171.99281201596, + "value": 44653.353783363804, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11617,7 +14491,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2026, - "value": 46058.866814268324, + "value": 46131.613207371694, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11626,7 +14504,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2027, - "value": 46923.63741254213, + "value": 47073.502879079664, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11635,7 +14517,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2028, - "value": 47337.90189430204, + "value": 47372.95055950299, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11644,7 +14530,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2029, - "value": 48018.33585686029, + "value": 48085.54776182688, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11653,7 +14543,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2030, - "value": 48015.10342326575, + "value": 48110.89818838793, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11662,7 +14556,11 @@ { "metric_id": "deciles.mean_income.5", "year": 2031, - "value": 49372.799605199805, + "value": 48857.4314782977, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11671,7 +14569,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2025, - "value": 48820.920957344184, + "value": 48283.828312490165, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11680,7 +14582,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2026, - "value": 50247.0931869786, + "value": 49405.72352548862, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11689,7 +14595,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2027, - "value": 51421.09655308313, + "value": 50162.973103580414, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11698,7 +14608,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2028, - "value": 52276.76979311605, + "value": 51093.240160099325, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11707,7 +14621,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2029, - "value": 52861.797979942385, + "value": 52375.72388322683, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11716,7 +14634,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2030, - "value": 54632.18051219701, + "value": 53906.52504776521, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11725,7 +14647,11 @@ { "metric_id": "deciles.mean_income.6", "year": 2031, - "value": 56251.724813561, + "value": 55326.758982061125, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11734,7 +14660,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2025, - "value": 55387.77506364148, + "value": 54559.719261696424, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11743,7 +14673,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2026, - "value": 56756.00462516803, + "value": 55738.7030110715, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11752,7 +14686,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2027, - "value": 57200.40880691023, + "value": 56769.742467299, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11761,7 +14699,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2028, - "value": 58134.97463181802, + "value": 57552.98337993226, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11770,7 +14712,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2029, - "value": 58986.25591531483, + "value": 58018.55725963795, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11779,7 +14725,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2030, - "value": 60251.89005484455, + "value": 59147.87210110048, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11788,7 +14738,11 @@ { "metric_id": "deciles.mean_income.7", "year": 2031, - "value": 62408.99721436145, + "value": 60637.60697260916, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11797,7 +14751,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2025, - "value": 70806.00916651895, + "value": 69236.41966938555, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11806,7 +14764,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2026, - "value": 72028.31133150091, + "value": 70232.37813773785, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11815,7 +14777,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2027, - "value": 73225.66554327721, + "value": 71531.33103123217, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11824,7 +14790,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2028, - "value": 74341.65681045009, + "value": 72791.02280987396, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11833,7 +14803,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2029, - "value": 75651.30440928646, + "value": 74106.60242542511, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11842,7 +14816,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2030, - "value": 76728.81669615183, + "value": 75628.31856190029, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11851,7 +14829,11 @@ { "metric_id": "deciles.mean_income.8", "year": 2031, - "value": 77557.30506779443, + "value": 77611.71088167054, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11860,7 +14842,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2025, - "value": 78613.5574256512, + "value": 79276.26633204655, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11869,7 +14855,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2026, - "value": 80276.59014426098, + "value": 81581.90782711905, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11878,7 +14868,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2027, - "value": 81556.99374054834, + "value": 83106.87038322185, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11887,7 +14881,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2028, - "value": 83394.03807203949, + "value": 84575.49840158923, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11896,7 +14894,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2029, - "value": 84834.09750237051, + "value": 85864.95896332423, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11905,7 +14907,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2030, - "value": 86472.88055967825, + "value": 87649.69567899546, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11914,7 +14920,11 @@ { "metric_id": "deciles.mean_income.9", "year": 2031, - "value": 90591.67357463166, + "value": 90654.08333059541, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11923,7 +14933,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2025, - "value": 121628.70831615487, + "value": 118030.04118236576, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11932,7 +14946,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2026, - "value": 124668.9979412319, + "value": 120826.03138084599, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11941,7 +14959,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2027, - "value": 125904.42260266874, + "value": 122592.51237304772, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11950,7 +14972,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2028, - "value": 128224.60518010141, + "value": 124339.6078872293, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11959,7 +14985,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2029, - "value": 130772.6528624675, + "value": 126219.29213328264, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11968,7 +14998,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2030, - "value": 133647.48170630506, + "value": 128486.66905357645, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11977,7 +15011,11 @@ { "metric_id": "deciles.mean_income.10", "year": 2031, - "value": 135669.54991935962, + "value": 131631.3290311054, + "suppressed": false, + "excludes": [ + "spending.disabled_students_allowance" + ], "unit": "gbp_household", "kind": "decile", "partition": null, @@ -11986,7 +15024,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2025, - "value": 6231978976.779907, + "value": 6222687308.254578, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -11995,7 +15035,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2026, - "value": 6514784443.9122925, + "value": 6508759614.741272, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12004,7 +15046,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2027, - "value": 6724056114.445618, + "value": 6714986157.079834, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12013,7 +15057,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2028, - "value": 6921661403.251404, + "value": 6913272334.154663, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12022,7 +15068,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2029, - "value": 7132082771.443726, + "value": 7118288039.173706, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12031,7 +15079,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2030, - "value": 7370366483.297607, + "value": 7356266082.489136, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12040,7 +15090,9 @@ { "metric_id": "reforms.basic_rate_plus_1pp", "year": 2031, - "value": 7650165153.850464, + "value": 7633159472.480713, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12049,7 +15101,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2025, - "value": 6375058856.060486, + "value": 6366582048.682007, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12058,7 +15112,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2026, - "value": 6667124628.2039795, + "value": 6660786957.415405, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12067,7 +15123,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2027, - "value": 6885617422.428345, + "value": 6876392706.334412, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12076,7 +15134,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2028, - "value": 7091326778.457153, + "value": 7081815371.239807, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12085,7 +15145,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2029, - "value": 7306154419.062439, + "value": 7290951197.121887, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12094,7 +15156,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2030, - "value": 7546544692.554932, + "value": 7531692335.065491, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12103,7 +15167,9 @@ { "metric_id": "reform_targets.basic_rate_plus_1pp", "year": 2031, - "value": 7822055582.338684, + "value": 7809724834.753845, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12112,7 +15178,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2025, - "value": 3666986552.897339, + "value": 3548582094.3916626, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12121,7 +15189,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2026, - "value": 3977030845.7789307, + "value": 3840615655.607727, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12130,7 +15200,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2027, - "value": 4223083637.6138306, + "value": 4076238853.5632935, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12139,7 +15211,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2028, - "value": 4369415244.928589, + "value": 4299497163.4904785, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12148,7 +15222,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2029, - "value": 4609416868.0217285, + "value": 4571859488.912842, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12157,7 +15233,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2030, - "value": 4888880522.356812, + "value": 4855874568.11499, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12166,7 +15244,9 @@ { "metric_id": "reforms.higher_rate_plus_1pp", "year": 2031, - "value": 5111936398.052734, + "value": 5076062695.494751, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12175,7 +15255,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2025, - "value": 3671386650.1765137, + "value": 3554390535.588806, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12184,7 +15266,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2026, - "value": 3982920392.3969727, + "value": 3848232118.605835, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12193,7 +15277,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2027, - "value": 4229920079.0026245, + "value": 4084949832.10614, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12202,7 +15288,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2028, - "value": 4377131336.593445, + "value": 4309251584.078491, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12211,7 +15299,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2029, - "value": 4618077924.957153, + "value": 4582787139.6779785, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12220,7 +15310,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2030, - "value": 4898722761.492981, + "value": 4868223711.304871, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12229,7 +15321,9 @@ { "metric_id": "reform_targets.higher_rate_plus_1pp", "year": 2031, - "value": 5122219947.487732, + "value": 5089139733.0060425, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12238,7 +15332,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2025, - "value": -3823707824.9178467, + "value": -3822246893.5978394, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12247,7 +15343,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2026, - "value": -3938856676.534912, + "value": -3970099818.8598022, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12256,7 +15354,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2027, - "value": -4026654105.0057983, + "value": -4052308604.757263, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12265,7 +15365,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2028, - "value": -4071413798.9171143, + "value": -4101725729.7006836, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12274,7 +15376,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2029, - "value": -4134957766.1256104, + "value": -4183390396.729248, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12283,7 +15387,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2030, - "value": -4229688107.533081, + "value": -4290067047.536621, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12292,7 +15398,9 @@ { "metric_id": "reforms.personal_allowance_13000", "year": 2031, - "value": -1711397144.4260254, + "value": -2026458045.3624268, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12301,7 +15409,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2025, - "value": -3955109908.810852, + "value": -3954063036.4473877, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12310,7 +15420,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2026, - "value": -4086527957.4680176, + "value": -4117335693.9852905, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12319,7 +15431,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2027, - "value": -4183341947.9663086, + "value": -4209427536.251709, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12328,7 +15442,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2028, - "value": -4230614997.3900757, + "value": -4261098604.1522827, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12337,7 +15453,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2029, - "value": -4295011805.698914, + "value": -4343355439.515015, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12346,7 +15464,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2030, - "value": -4392504289.538818, + "value": -4452755767.392151, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12355,7 +15475,9 @@ { "metric_id": "reform_targets.personal_allowance_13000", "year": 2031, - "value": -1765597207.2024536, + "value": -1780300034.5704956, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12364,7 +15486,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2025, - "value": -1977222467.3527832, + "value": -1978301080.5203857, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12373,7 +15497,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2026, - "value": -1744958365.3483887, + "value": -1746066703.213379, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12382,7 +15508,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2027, - "value": -1629814465.277588, + "value": -1629668073.130188, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12391,7 +15519,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2028, - "value": -1534763674.378479, + "value": -1530858259.5943604, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12400,7 +15530,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2029, - "value": -1428204556.7736816, + "value": -1428323970.4534912, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12409,7 +15541,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2030, - "value": -1322075255.131958, + "value": -1325576053.7683105, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12418,7 +15552,9 @@ { "metric_id": "reforms.child_benefit_additional_25", "year": 2031, - "value": -1215395543.2149658, + "value": -1219354845.269043, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12427,7 +15563,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2025, - "value": 2332987565.3313274, + "value": 2330588637.038988, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12436,7 +15574,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2026, - "value": 2143319353.6422234, + "value": 2141486725.888443, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12445,7 +15585,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2027, - "value": 2025631616.9500465, + "value": 2023775956.2239304, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12454,7 +15596,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2028, - "value": 1921833405.853962, + "value": 1920161279.352703, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12463,7 +15607,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2029, - "value": 1815708316.6814766, + "value": 1813779891.1509056, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12472,7 +15618,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2030, - "value": 1706257920.2213326, + "value": 1704712527.6125221, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12481,7 +15629,9 @@ { "metric_id": "reform_targets.child_benefit_additional_25", "year": 2031, - "value": 1592582974.3537292, + "value": 1591189257.2969856, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12490,7 +15640,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2025, - "value": -19987138619.665894, + "value": -20527842919.80768, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12499,7 +15651,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2026, - "value": -20585697292.380737, + "value": -21100518260.464355, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12508,7 +15662,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2027, - "value": -20867244208.72113, + "value": -21383710367.08246, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12517,7 +15673,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2028, - "value": -21212204041.55127, + "value": -21736411484.46698, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12526,7 +15684,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2029, - "value": -21266028636.272034, + "value": -21748894837.86438, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12535,7 +15695,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2030, - "value": -21323353884.143677, + "value": -21876938936.24524, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12544,7 +15706,9 @@ { "metric_id": "reforms.uc_taper_20pc", "year": 2031, - "value": -21417216822.362915, + "value": -22330952654.634155, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12553,7 +15717,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2025, - "value": 20026032932.944794, + "value": 20519585447.911682, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12562,7 +15728,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2026, - "value": 20623599493.57924, + "value": 21090575767.801956, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12571,7 +15739,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2027, - "value": 20905978230.628372, + "value": 21373214076.36197, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12580,7 +15750,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2028, - "value": 21252138646.154373, + "value": 21725838011.858963, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12589,7 +15761,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2029, - "value": 21307648843.63382, + "value": 21742175658.693848, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12598,7 +15772,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2030, - "value": 21364495790.550797, + "value": 21868780192.75914, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12607,7 +15783,9 @@ { "metric_id": "reform_targets.uc_taper_20pc", "year": 2031, - "value": 21460406654.06598, + "value": 22322933920.91278, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12616,7 +15794,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2025, - "value": 11191545342.31836, + "value": 11064401706.310059, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12625,7 +15805,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2026, - "value": 11645153494.695984, + "value": 11510324242.230225, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12634,7 +15816,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2027, - "value": 11972824309.717712, + "value": 11834108533.458801, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12643,7 +15827,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2028, - "value": 12257716047.155273, + "value": 12138956950.555908, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12652,7 +15838,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2029, - "value": 12566231791.670166, + "value": 12464179087.056396, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12661,7 +15849,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2030, - "value": 12912911611.737793, + "value": 12812306848.333618, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12670,7 +15860,9 @@ { "metric_id": "reforms.employee_nics_main_10pc", "year": 2031, - "value": 13392154732.762207, + "value": 13289675311.854004, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12679,7 +15871,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2025, - "value": 11458770099.1045, + "value": 11329957618.034607, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12688,7 +15882,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2026, - "value": 11919342691.629387, + "value": 11791691034.516708, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12697,7 +15893,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2027, - "value": 12255153227.885277, + "value": 12126516761.37503, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12706,7 +15904,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2028, - "value": 12566120848.304436, + "value": 12442433003.455788, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12715,7 +15915,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2029, - "value": 12878237799.178757, + "value": 12770326792.025597, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12724,7 +15926,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2030, - "value": 13226533370.28511, + "value": 13120133297.630943, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12733,7 +15937,9 @@ { "metric_id": "reform_targets.employee_nics_main_10pc", "year": 2031, - "value": 13713984420.560326, + "value": 13607405556.501923, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12742,7 +15948,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2025, - "value": 31259132637.84729, + "value": 30863021596.225952, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12751,7 +15959,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2026, - "value": 32099610265.377075, + "value": 31692844228.266296, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12760,7 +15970,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2027, - "value": 32862745005.17981, + "value": 32446311527.309143, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12769,7 +15981,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2028, - "value": 33654079646.918335, + "value": 33227617036.870483, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12778,7 +15992,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2029, - "value": 34478200723.7262, + "value": 34041293064.6344, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12787,7 +16003,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2030, - "value": 35326019624.22656, + "value": 34878374451.18152, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12796,7 +16014,9 @@ { "metric_id": "reforms.vat_standard_plus_2pp", "year": 2031, - "value": 36187574723.726685, + "value": 35728921387.40991, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12805,7 +16025,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2025, - "value": 31259131903.187378, + "value": 30863018576.160828, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12814,7 +16036,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2026, - "value": 32099610338.78839, + "value": 31692847105.070984, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12823,7 +16047,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2027, - "value": 32862746112.62982, + "value": 32446312252.777954, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12832,7 +16058,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2028, - "value": 33654077425.53827, + "value": 33227616036.71393, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12841,7 +16069,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2029, - "value": 34478201429.00751, + "value": 34041297530.920654, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12850,7 +16080,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2030, - "value": 35326020466.349304, + "value": 34878372560.67273, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12859,7 +16091,9 @@ { "metric_id": "reform_targets.vat_standard_plus_2pp", "year": 2031, - "value": 36187575185.13623, + "value": 35728918405.68787, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12868,7 +16102,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2025, - "value": 2825467413.079834, + "value": 2798505716.117798, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12877,7 +16113,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2026, - "value": 3049458999.274414, + "value": 3023336469.8702393, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12886,7 +16124,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2027, - "value": 3238599613.133667, + "value": 3213071953.8966064, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12895,7 +16135,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2028, - "value": 3549931915.0806885, + "value": 3394779601.3947754, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12904,7 +16146,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2029, - "value": 3756212324.8634033, + "value": 3594508773.6256104, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12913,7 +16157,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2030, - "value": 3997750150.373169, + "value": 3822543323.427368, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12922,7 +16168,9 @@ { "metric_id": "reforms.additional_rate_plus_3pp", "year": 2031, - "value": 4332961887.065674, + "value": 4125743282.83667, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -12931,7 +16179,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2025, - "value": 2825466843.0894775, + "value": 2798504994.492798, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12940,7 +16190,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2026, - "value": 3049458483.8516235, + "value": 3023336460.849365, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12949,7 +16201,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2027, - "value": 3238600146.871582, + "value": 3213071862.152466, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12958,7 +16212,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2028, - "value": 3549931957.5039062, + "value": 3394780483.9908447, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12967,7 +16223,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2029, - "value": 3756211982.0652466, + "value": 3594509097.7042236, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12976,7 +16234,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2030, - "value": 3997750696.064453, + "value": 3822542242.9839478, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12985,7 +16245,9 @@ { "metric_id": "reform_targets.additional_rate_plus_3pp", "year": 2031, - "value": 4332963129.139282, + "value": 4125743186.885071, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -12994,7 +16256,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2025, - "value": -3024426671.0059814, + "value": -3038583641.996826, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13003,7 +16267,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2026, - "value": -3159166047.906311, + "value": -3168138984.6657715, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13012,7 +16278,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2027, - "value": -3213933471.489624, + "value": -3225527730.8588867, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13021,7 +16289,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2028, - "value": -3272574045.103821, + "value": -3282105874.806885, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13030,7 +16300,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2029, - "value": -3304278267.8723145, + "value": -3307346412.4144287, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13039,7 +16311,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2030, - "value": -3329443110.629883, + "value": -3331520951.5113525, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13048,7 +16322,9 @@ { "metric_id": "reforms.uc_standard_allowance_plus_10pc", "year": 2031, - "value": -3364831042.0513916, + "value": -3373553161.4073486, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13057,7 +16333,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2025, - "value": 3028001830.553894, + "value": 3041523709.2714844, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13066,7 +16344,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2026, - "value": 3162547160.014694, + "value": 3171636381.1340485, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13075,7 +16355,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2027, - "value": 3218303161.6876984, + "value": 3226364754.786072, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13084,7 +16366,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2028, - "value": 3277244745.4717865, + "value": 3282674243.09433, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13093,7 +16377,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2029, - "value": 3309693715.520218, + "value": 3312220329.68396, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13102,7 +16388,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2030, - "value": 3335295107.935898, + "value": 3337058613.859558, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13111,7 +16399,9 @@ { "metric_id": "reform_targets.uc_standard_allowance_plus_10pc", "year": 2031, - "value": 3368860316.26828, + "value": 3377518834.7556, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13120,7 +16410,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2025, - "value": -9035141432.653503, + "value": -8927432756.88562, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13129,7 +16421,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2026, - "value": -9262484295.463745, + "value": -9142939686.93689, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13138,7 +16432,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2027, - "value": -9298804936.553955, + "value": -9062725924.750671, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13147,7 +16443,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2028, - "value": -9371209948.825623, + "value": -9114943836.081665, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13156,7 +16454,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2029, - "value": -9561709368.57013, + "value": -9306907164.58667, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13165,7 +16465,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2030, - "value": -9807065305.57251, + "value": -9548783655.451294, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13174,7 +16476,9 @@ { "metric_id": "reforms.state_pension_plus_10pc", "year": 2031, - "value": -10306378596.512695, + "value": -9816145928.907349, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform", "partition": null, @@ -13183,7 +16487,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2025, - "value": 12360890321.875732, + "value": 12250304093.31047, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13192,7 +16498,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2026, - "value": 12831378854.052383, + "value": 12711504064.702667, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13201,7 +16509,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2027, - "value": 13012698482.677444, + "value": 12828062954.26825, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13210,7 +16520,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2028, - "value": 13223877715.22789, + "value": 13020927023.28334, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13219,7 +16531,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2029, - "value": 13614122674.000916, + "value": 13405169512.460327, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13228,7 +16542,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2030, - "value": 14017293680.398499, + "value": 13802157356.131042, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13237,7 +16553,9 @@ { "metric_id": "reform_targets.state_pension_plus_10pc", "year": 2031, - "value": 14429529140.882904, + "value": 14208043135.872528, + "suppressed": false, + "excludes": [], "unit": "gbp", "kind": "reform_target", "partition": null, @@ -13435,7 +16753,7 @@ "2030", "2031" ], - "detail": "2025-26: 76 units below zero, a weighted \u00a3-69,996,281 in total; 2026-27: 76 units below zero, a weighted \u00a3-71,878,301 in total; 2027-28: 76 units below zero, a weighted \u00a3-73,587,136 in total; 2028-29: 76 units below zero, a weighted \u00a3-75,359,106 in total; 2029-30: 76 units below zero, a weighted \u00a3-77,204,504 in total; 2030-31: 76 units below zero, a weighted \u00a3-79,102,963 in total; 2031-32: 76 units below zero, a weighted \u00a3-81,032,183 in total" + "detail": "2025-26: 76 units below zero, a weighted \u00a3-67,632,237 in total; 2026-27: 76 units below zero, a weighted \u00a3-69,450,693 in total; 2027-28: 76 units below zero, a weighted \u00a3-71,101,816 in total; 2028-29: 76 units below zero, a weighted \u00a3-72,813,940 in total; 2029-30: 76 units below zero, a weighted \u00a3-74,597,010 in total; 2030-31: 76 units below zero, a weighted \u00a3-76,431,349 in total; 2031-32: 76 units below zero, a weighted \u00a3-78,295,212 in total" }, { "identity_id": "inert:vat", @@ -14625,6 +17943,38 @@ "failing_scopes": [], "detail": "" }, + { + "identity_id": "finite:statutory_paternity_pay", + "year": null, + "status": "pass", + "severity": "fail", + "failing_scopes": [], + "detail": "" + }, + { + "identity_id": "sign:statutory_paternity_pay", + "year": null, + "status": "pass", + "severity": "fail", + "failing_scopes": [], + "detail": "" + }, + { + "identity_id": "inert:statutory_paternity_pay", + "year": null, + "status": "warn", + "severity": "warn", + "failing_scopes": [ + "2025", + "2026", + "2027", + "2028", + "2029", + "2030", + "2031" + ], + "detail": "2025-26: zero for every unit; 2026-27: zero for every unit; 2027-28: zero for every unit; 2028-29: zero for every unit; 2029-30: zero for every unit; 2030-31: zero for every unit; 2031-32: zero for every unit" + }, { "identity_id": "finite:statutory_sick_pay", "year": null, @@ -14948,10 +18298,10 @@ { "identity_id": "sign:disabled_students_allowance", "year": null, - "status": "pass", + "status": "skipped", "severity": "fail", "failing_scopes": [], - "detail": "" + "detail": "2025-26: not checked, from fewer than 10 records; 2026-27: not checked, from fewer than 10 records; 2027-28: not checked, from fewer than 10 records; 2028-29: not checked, from fewer than 10 records; 2029-30: not checked, from fewer than 10 records; 2030-31: not checked, from fewer than 10 records; 2031-32: not checked, from fewer than 10 records" }, { "identity_id": "inert:disabled_students_allowance", @@ -15140,18 +18490,10 @@ { "identity_id": "overlap_guard:property_transaction_tax_geography", "year": null, - "status": "fail", + "status": "pass", "severity": "fail", - "failing_scopes": [ - "2025", - "2026", - "2027", - "2028", - "2029", - "2030", - "2031" - ], - "detail": "2025-26: 86 households have more than one, a weighted 39,273; 2026-27: 86 households have more than one, a weighted 39,422; 2027-28: 88 households have more than one, a weighted 39,718; 2028-29: 92 households have more than one, a weighted 40,335; 2029-30: 94 households have more than one, a weighted 40,724; 2030-31: 96 households have more than one, a weighted 41,410; 2031-32: 104 households have more than one, a weighted 42,213" + "failing_scopes": [], + "detail": "" }, { "identity_id": "overlap_guard:local_tax_geography", @@ -15740,22 +19082,26 @@ { "identity_id": "counted_once:statutory_maternity_pay", "year": null, - "status": "fail", + "status": "pass", "severity": "fail", - "failing_scopes": [ - "" - ], - "detail": "gov_spending: universal_credit rises by 0.0068 per unit of statutory_maternity_pay; household_benefits: universal_credit rises by 0.0068 per unit of statutory_maternity_pay" + "failing_scopes": [], + "detail": "" + }, + { + "identity_id": "counted_once:statutory_paternity_pay", + "year": null, + "status": "pass", + "severity": "fail", + "failing_scopes": [], + "detail": "" }, { "identity_id": "counted_once:statutory_sick_pay", "year": null, - "status": "fail", + "status": "pass", "severity": "fail", - "failing_scopes": [ - "" - ], - "detail": "gov_spending: universal_credit rises by 0.0099 per unit of statutory_sick_pay; household_benefits: universal_credit rises by 0.0099 per unit of statutory_sick_pay" + "failing_scopes": [], + "detail": "" }, { "identity_id": "counted_once:ssmg", From ece60cb962c9c13934c4d35c0f3030842cd9db33 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 02:39:11 -0400 Subject: [PATCH 08/18] Re-run a cancelled own-tree run; judge only the newest runs' checks Round 3 of #635: - A job timeout cancels its run, and refresh re-ran only failed and timed-out runs, so a cancelled own-tree run blocked promotion with nothing to re-run it (requalification fires only when main moves). Refresh now re-runs the newest run of each workflow and event when it failed or timed out (its failed jobs) or was cancelled (in full), and stops after MAX_RUN_ATTEMPTS, so a genuine failure is not retried until a flaky test passes. - Refresh re-ran every failed run, including ones a newer run replaced, whose re-run checks then came back newest and blocked. It now leaves replaced runs alone, and the promotion gate does not count checks from a replaced run's check suite. Checks outside any workflow run still count. - The still-eligible recheck gets GH_TOKEN: without it, an earlier attempt's failed run read as live until its stage expired. - cmd_promote returns every ready candidate except the target to draft, and a failed return to draft now fails the run (_set_ready reports it; _promote returns False only then). - Tests: the cancelled-no-jobs own-tree run, a replaced run's re-run checks, an app's check, force reaching still-eligible and resolve's payload, the sweep and a failed undo. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 3 + scripts/check_uk_candidate.py | 159 ++++++++++++++++------ tests/test_uk_qualification_pipeline.py | 107 ++++++++++++++- tests/test_uk_qualification_workflow.py | 58 ++++++-- 4 files changed, 269 insertions(+), 58 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 84166c437..3abfae736 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -188,6 +188,9 @@ jobs: id: duplicate env: ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + # still-eligible asks gh whether an earlier attempt's run failed; + # without a token every unexpired failed run would read as live. + GH_TOKEN: ${{ github.token }} run: | .github/qualification-ledger.sh checkout ledger if [[ -d "$ATTEMPT" ]]; then diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 19516679f..704205646 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -569,11 +569,33 @@ def _open_candidate_branches() -> dict[str, str]: return {item["headRefName"]: item["headRefOid"] for item in json.loads(listing)} -def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: - """Re-evaluate failed PR checks now that their exact receipt is published. +#: A run is re-run until it has made this many attempts in all, so a genuine +#: failure is not retried until a flaky test happens to pass. +MAX_RUN_ATTEMPTS = 3 + + +def _newest_runs(runs: list[dict[str, Any]]) -> dict[tuple[Any, Any], dict[str, Any]]: + """The newest run of each workflow (``path``) and event.""" + + newest: dict[tuple[Any, Any], dict[str, Any]] = {} + for run in runs: + key = (run.get("path"), run.get("event")) + if key not in newest or run.get("id", 0) > newest[key].get("id", 0): + newest[key] = run + return newest + - The PR workflow does not subscribe to edits or ready_for_review events. - GITHUB_TOKEN may re-run existing failed runs with actions: write. +def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: + """Re-run a candidate's unsuccessful newest runs once its receipt is out. + + The PR workflow does not subscribe to edits or ready_for_review events, + and the run of the code-changes workflow on the candidate's own tree is + dispatched once, by ``prepare``. So for each workflow and event the + newest run on the candidate commit is re-run when it failed, timed out + or was cancelled (a job timeout cancels the run): a failed or timed-out + one by its failed jobs, a cancelled one in full. Older runs a newer one + replaced are left alone, and no run is re-run past ``MAX_RUN_ATTEMPTS``. + GITHUB_TOKEN may re-run existing runs with actions: write. """ branches = _open_candidate_branches() @@ -589,34 +611,35 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={head}&per_page=100", "--paginate", "--jq", - ".workflow_runs[] | {id, head_sha, event, status, conclusion} | tojson", + ".workflow_runs[] | {id, head_sha, path, event, status, conclusion," + " run_attempt} | tojson", ], check=True, capture_output=True, text=True, ).stdout - for line in listing.splitlines(): - run = json.loads(line) + runs = [ + run + for run in (json.loads(line) for line in listing.splitlines() if line) + if run["head_sha"] == head + and run["event"] in ("pull_request", "workflow_dispatch") + ] + for run in _newest_runs(runs).values(): if ( - run["head_sha"] != head - or run["event"] not in ("pull_request", "workflow_dispatch") - or run["status"] != "completed" - or run["conclusion"] not in {"failure", "timed_out"} + run["status"] != "completed" + or run["conclusion"] not in {"failure", "timed_out", "cancelled"} + or int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS ): continue subprocess.run( - [ - "gh", - "run", - "rerun", - str(run["id"]), - "--repo", - LEDGER_REPOSITORY, - "--failed", - ], + ["gh", "run", "rerun", str(run["id"]), "--repo", LEDGER_REPOSITORY] + + ([] if run["conclusion"] == "cancelled" else ["--failed"]), check=True, ) - print(f"Re-running failed PR checks in run {run['id']} at {head}") + print( + f"Re-running {run['conclusion']} run {run['id']} " + f"({run.get('path')}, {run['event']}) at {head}" + ) return 0 @@ -653,21 +676,65 @@ def cmd_promote(args: argparse.Namespace) -> int: ], check=False, ) + # Only the plan's target may be ready, and only while _promote finds it + # on current main: any other ready candidate (left by a run that could + # not undo it, or by older code) goes back to draft. + returned = [ + _set_ready(branch, False) + for branch in sorted(_ready_candidate_branches() - {plan.ready_branch}) + ] + safe = True if plan.ready_branch is not None and plan.ready_commit is not None: - _promote( + safe = _promote( plan.ready_branch, plan.ready_commit, automatic=config()["promotion"]["automatic"], ) - return 0 + return 0 if all(returned) and safe else 1 -def _set_ready(branch: str, ready: bool) -> None: - subprocess.run( - ["gh", "pr", "ready", branch, "--repo", LEDGER_REPOSITORY] - + ([] if ready else ["--undo"]), - check=False, +def _set_ready(branch: str, ready: bool) -> bool: + """Mark a candidate's pull request ready or draft; whether that worked. + + A draft that stays ready could be merged onto a moved main, so a failed + return to draft is reported as an error for the run to fail on. + """ + + done = ( + subprocess.run( + ["gh", "pr", "ready", branch, "--repo", LEDGER_REPOSITORY] + + ([] if ready else ["--undo"]), + check=False, + ).returncode + == 0 ) + if not done and not ready: + print(f"::error::could not return {branch} to draft") + return done + + +def _ready_candidate_branches() -> set[str]: + """Open candidate pull requests that are marked ready for review.""" + + listing = subprocess.run( + [ + "gh", + "pr", + "list", + "--repo", + LEDGER_REPOSITORY, + "--state", + "open", + "--search", + "head:candidate/uk/", + "--json", + "headRefName,isDraft", + ], + check=True, + capture_output=True, + text=True, + ).stdout + return {item["headRefName"] for item in json.loads(listing) if not item["isDraft"]} def _promote(branch: str, commit: str, *, automatic: bool) -> bool: @@ -682,6 +749,9 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: passed, and main is then moved to the commit by a push leased on that parent: if main is anything else at that instant, nothing changes. GitHub then records the pull request as merged. + + Returns False only when a return to draft failed, which leaves a ready + pull request that a merge could combine with other code. """ fetched = subprocess.run( @@ -695,19 +765,18 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: listed = _git_out("ls-remote", "origin", "refs/heads/main").split() main = listed[0] if listed else "" if head != commit or not parent or parent != main: - _set_ready(branch, False) print( f"not promotable now: {branch} is not the qualified commit " f"{commit[:12]} built on current main; it is requalified on current main" ) - return False + return _set_ready(branch, False) if not automatic: _set_ready(branch, True) - return False + return True waiting = _unfinished_checks(commit) if waiting: print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) - return False + return True _set_ready(branch, True) pushed = subprocess.run( [ @@ -722,12 +791,11 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: text=True, ) if pushed.returncode != 0: - _set_ready(branch, False) print( f"automatic promotion skipped: main is no longer {parent[:12]}; " "the pair is requalified on current main" ) - return False + return _set_ready(branch, False) print(f"promoted {branch} at {commit[:12]} to main") return True @@ -746,8 +814,10 @@ def _unfinished_checks(commit: str) -> list[str]: each workflow and event, the newest run must have finished successfully, and for each check name the newest check must have finished as success, skipped or neutral, so an older cancelled or failed execution that a - newer one replaced never blocks. A run still registering its jobs, none - reported, or an unreadable list waits. + newer one replaced never blocks. Checks of a run that a newer run of the + same workflow and event replaced are not counted at all, so re-running + it cannot block. A run still registering its jobs, none reported, or an + unreadable list waits. """ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: @@ -766,21 +836,24 @@ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: checks = listing( f"repos/{LEDGER_REPOSITORY}/commits/{commit}/check-runs?per_page=100", - ".check_runs[] | {id, name, status, conclusion} | tojson", + ".check_runs[] | {id, name, status, conclusion," + " check_suite_id: .check_suite.id} | tojson", ) runs = listing( f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={commit}&per_page=100", - ".workflow_runs[] | {id, path, event, status, conclusion} | tojson", + ".workflow_runs[] | {id, path, event, status, conclusion, check_suite_id}" + " | tojson", ) if checks is None or runs is None: return ["the checks could not be read"] - newest_runs: dict[tuple[Any, Any], dict[str, Any]] = {} - for run in runs: - key = (run.get("path"), run.get("event")) - if key not in newest_runs or run.get("id", 0) > newest_runs[key].get("id", 0): - newest_runs[key] = run + newest_runs = _newest_runs(runs) + replaced_suites = {run.get("check_suite_id") for run in runs} - { + run.get("check_suite_id") for run in newest_runs.values() + } newest_checks: dict[Any, dict[str, Any]] = {} for check in checks: + if check.get("check_suite_id") in replaced_suites: + continue name = check.get("name") if name not in newest_checks or check.get("id", 0) > newest_checks[name].get( "id", 0 diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 8811be13c..de39d6e89 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -1024,6 +1024,59 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): [], ), "no checks yet": ([], RUNS_PASSED, True, "parent", 0, True, False, []), + # Round 3 of #635: a dispatch cancelled while queued has no jobs and so + # no checks; the run itself must still block. + "the own-tree run cancelled with no jobs": ( + CHECKS_PASSED, + [RUNS_PASSED[0], _run("workflow_dispatch", conclusion="cancelled", id=11)], + True, + "parent", + 0, + True, + False, + [], + ), + # Round 3 of #635: a replaced run re-run later gets newer check ids; its + # checks must not count, whatever they conclude. + "a replaced run's re-run checks": ( + [ + {**_check("Lint", id=1), "check_suite_id": 100}, + {**_check("Test (3.13)", id=2), "check_suite_id": 100}, + {**_check("Own tree", id=3), "check_suite_id": 110}, + { + **_check("Test (3.13)", conclusion="failure", id=50), + "check_suite_id": 90, + }, + ], + [ + {**_run("pull_request", id=10), "check_suite_id": 100}, + {**_run("workflow_dispatch", id=11), "check_suite_id": 110}, + {**_run("pull_request", conclusion="failure", id=9), "check_suite_id": 90}, + ], + True, + "parent", + 0, + True, + True, + ["ready"], + ), + # An app's check outside any workflow run still counts. + "an app's failing check": ( + [ + {**_check("Lint", id=1), "check_suite_id": 100}, + {**_check("External", conclusion="failure", id=5), "check_suite_id": 500}, + ], + [ + {**_run("pull_request", id=10), "check_suite_id": 100}, + {**_run("workflow_dispatch", id=11), "check_suite_id": 110}, + ], + True, + "parent", + 0, + True, + False, + [], + ), "the checks could not be read": ( None, RUNS_PASSED, @@ -1139,6 +1192,7 @@ def run(argv, **kwargs): monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": automatic}}) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) + monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: set()) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert [ "undo" if "--undo" in argv else "ready" @@ -1166,6 +1220,55 @@ def run(argv, **kwargs): assert (f"promoted {branch}" in capsys.readouterr().out) is promotes +@pytest.mark.parametrize("undo_fails", [False, True]) +def test_promotion_returns_every_other_ready_candidate_to_draft( + monkeypatch, tmp_path, undo_fails +): + """Round 3 of #635: only the plan's target may be ready, so a candidate + left ready by a run whose undo failed, or by older code, is returned to + draft; a failed return to draft fails the run so it is seen.""" + + import argparse + import importlib.util + + qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) + branch = candidate_branch(qualified.pair) + commit = qualified.harness.git_commit + stale = "candidate/uk/stale-pair" + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_for_test", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + calls = [] + + def run(argv, **kwargs): + calls.append(argv) + out, code = "", 0 + if argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: + out = commit + elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: + out = "p" * 40 + elif argv[:2] == ["git", "ls-remote"]: + # Main moved: the target is kept draft too. + out = "m" * 40 + "\trefs/heads/main\n" + elif argv[:3] == ["gh", "pr", "ready"] and "--undo" in argv: + code = 1 if undo_fails else 0 + return SimpleNamespace(returncode=code, stdout=out, stderr="") + + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) + monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) + monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) + monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: {branch, stale}) + status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) + undone = [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] + # The stale one by the sweep, the target by _promote (main moved). + assert undone == [stale, branch] + assert status == (1 if undo_fails else 0) + + def test_automatic_promotion_is_on_with_its_basis_recorded(): config = json.loads( ( @@ -1756,6 +1859,8 @@ def test_resolution_preserves_authorization_to_recover_forced_work( ) assert cli.cmd_resolve(args) == 0 payload = json.loads(out.read_text()) + # The recheck under the preparation lock reads it (round 3 of #635). + assert payload["force"] is True assert payload["supersedes_receipt_sha256"] == terminal.unreviewed_sha256 attempt = attempt_directory(tmp_path, "uk", pair.pair_id, payload["attempt_key"]) stage_attempt( @@ -1767,7 +1872,7 @@ def test_resolution_preserves_authorization_to_recover_forced_work( args.force = False assert cli.cmd_resolve(args) == 0 recovery = json.loads(out.read_text()) - assert recovery["action"] == "qualify" + assert recovery["action"] == "qualify" and recovery["force"] is False assert recovery["base_commit"] == base assert recovery["attempt_key"] == (base[:12] if main_advanced else base[:12] + "-2") assert recovery["supersedes_receipt_sha256"] == terminal.unreviewed_sha256 diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 2bf4e7f56..b613ee4c2 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -157,6 +157,8 @@ def test_preparation_rechecks_eligibility_and_tests_the_candidates_own_tree(): names = [step.get("name") for step in steps] duplicate = steps[names.index("Check for an attempt staged by another run")] assert "check_uk_candidate.py still-eligible" in duplicate["run"] + # It asks gh whether an earlier attempt's run failed (round 3 of #635). + assert duplicate["env"]["GH_TOKEN"] == "${{ github.token }}" assert names.index("Install the base environment") < names.index( "Check for an attempt staged by another run" ) @@ -206,6 +208,12 @@ def test_still_eligible_reports_a_pair_another_run_is_qualifying(monkeypatch, tm ) assert cli.cmd_still_eligible(args) == 0 assert outputs[-1] == {"duplicate": "true"} + # A forced run is never turned into a duplicate (round 3 of #635: the + # recheck dropped force and still passed every test). + payload = json.loads(candidate.read_text()) + candidate.write_text(json.dumps({**payload, "force": True})) + assert cli.cmd_still_eligible(args) == 0 + assert outputs[-1] == {"duplicate": "false"} def test_preparation_syncs_the_candidate_lock_before_using_it(): @@ -329,7 +337,13 @@ def fake_run(argv, **kwargs): state = json.loads(state_path.read_text()) return SimpleNamespace( stdout=json.dumps( - [{"headRefName": branch, "headRefOid": state["head"]}] + [ + { + "headRefName": branch, + "headRefOid": state["head"], + "isDraft": state["status"] != "ready", + } + ] ) ) # Promotion reads the branch head and checks main is its parent. @@ -494,23 +508,35 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( head = receipt.harness.git_commit if head_matches else "f" * 40 monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [receipt]) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: head}) + code, docs, guard = ( + ".github/workflows/pr_code_changes.yaml", + ".github/workflows/pr_docs.yaml", + ".github/workflows/guard.yaml", + ) runs = [ { "id": identifier, "head_sha": sha, + "path": path, "event": event, "status": status, "conclusion": conclusion, + "run_attempt": attempt, } - for identifier, sha, event, status, conclusion in ( - (11, head, "pull_request", "completed", "failure"), - (12, head, "pull_request", "completed", "success"), - (13, head, "pull_request", "in_progress", "failure"), - (14, "a" * 40, "pull_request", "completed", "failure"), - (15, head, "push", "completed", "failure"), - (16, head, "pull_request", "completed", "timed_out"), - # The run on the candidate's own tree that promotion requires. - (17, head, "workflow_dispatch", "completed", "failure"), + for identifier, sha, path, event, status, conclusion, attempt in ( + # Replaced by run 16 of the same workflow and event: left alone, + # so its checks cannot come back newer (round 3 of #635). + (11, head, code, "pull_request", "completed", "failure", 1), + (12, head, docs, "pull_request", "completed", "success", 1), + (13, head, guard, "pull_request", "in_progress", None, 1), + (14, "a" * 40, code, "pull_request", "completed", "failure", 1), + (15, head, code, "push", "completed", "failure", 1), + (16, head, code, "pull_request", "completed", "timed_out", 1), + # The run on the candidate's own tree that promotion requires; a + # job timeout cancels the run (round 3 of #635). + (17, head, code, "workflow_dispatch", "completed", "cancelled", 1), + # Already run as often as the cap allows. + (18, head, docs, "workflow_dispatch", "completed", "failure", 3), ) ] calls = [] @@ -521,15 +547,19 @@ def fake_run(argv, **kwargs): assert "--paginate" in argv assert f"head_sha={receipt.harness.git_commit}" in argv[2] return SimpleNamespace(stdout="\n".join(json.dumps(run) for run in runs)) - assert argv[:3] == ["gh", "run", "rerun"] - assert "--failed" in argv and kwargs["check"] is True + assert argv[:3] == ["gh", "run", "rerun"] and kwargs["check"] is True return SimpleNamespace(returncode=0) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=fake_run)) args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) assert args.func(args) == 0 - reruns = [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] + reruns = { + call[3]: "--failed" in call + for call in calls + if call[:3] == ["gh", "run", "rerun"] + } if receipt.promotable and head_matches: - assert reruns == ["11", "16", "17"] + # Failed jobs only for the timed-out run; the cancelled one in full. + assert reruns == {"16": True, "17": False} else: assert calls == [] From b49d6ef31dbaadcf7fef248ba71572d72a039e67 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 04:46:30 -0400 Subject: [PATCH 09/18] Fail visibly on a stranded candidate; keep forks out of the sweep Round 4 of #635: - Medium: the first attempt of a qualified_exact_pair candidate's runs fails by design (its receipt is published after they start), so a cap of three left one re-run for flakes, then stranded the candidate while promote exited 0. MAX_RUN_ATTEMPTS is 4, and a candidate whose newest run is still unsuccessful at the cap fails the promote run with an error naming the runs to re-run by hand. - A re-run GitHub refuses (a run older than 30 days) no longer aborts refresh and the steps after it: it warns and refresh goes on. - Candidate pull requests are listed with their numbers and only from this repository's branches; the sweep returns stale ones to draft by number, so a fork's pull request with a candidate's branch name can neither shadow the real one nor make every run fail. - A target already marked ready goes back to draft while it waits on its checks. - Tests: a real listing with ready, draft and fork pull requests; each failed undo alone (sweep, target after main moved, target after a rejected lease); the cap at attempts 3 and 4; a refused re-run. Co-Authored-By: Claude Opus 5.5 --- scripts/check_uk_candidate.py | 124 ++++++++++++------- tests/test_uk_qualification_pipeline.py | 157 +++++++++++++++++++++--- tests/test_uk_qualification_workflow.py | 22 ++-- 3 files changed, 231 insertions(+), 72 deletions(-) diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 704205646..7418c730d 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -545,8 +545,12 @@ def cmd_requalify(args: argparse.Namespace) -> int: return 0 -def _open_candidate_branches() -> dict[str, str]: - """Open candidate branches and the commit each one's pull request is at.""" +def _candidate_pull_requests() -> list[dict[str, Any]]: + """Open candidate pull requests from this repository's own branches. + + ``head:candidate/uk/`` matches by prefix, and a fork's pull request can + carry the same branch name, so only same-repository ones are candidates. + """ listing = subprocess.run( [ @@ -560,18 +564,30 @@ def _open_candidate_branches() -> dict[str, str]: "--search", "head:candidate/uk/", "--json", - "headRefName,headRefOid", + "number,headRefName,headRefOid,isDraft,isCrossRepository", ], check=True, capture_output=True, text=True, ).stdout - return {item["headRefName"]: item["headRefOid"] for item in json.loads(listing)} + return [item for item in json.loads(listing) if not item.get("isCrossRepository")] + + +def _open_candidate_branches() -> dict[str, str]: + """Open candidate branches and the commit each one's pull request is at.""" + + return { + item["headRefName"]: item["headRefOid"] for item in _candidate_pull_requests() + } #: A run is re-run until it has made this many attempts in all, so a genuine -#: failure is not retried until a flaky test happens to pass. -MAX_RUN_ATTEMPTS = 3 +#: failure is not retried until a flaky test happens to pass. The first +#: attempt of a qualified_exact_pair candidate's runs fails by design (its +#: ledger receipt is published after they start), so this leaves the re-run +#: that sees the receipt and two more for flakes. A candidate whose newest +#: run is still unsuccessful at the cap fails the promote run visibly. +MAX_RUN_ATTEMPTS = 4 def _newest_runs(runs: list[dict[str, Any]]) -> dict[tuple[Any, Any], dict[str, Any]]: @@ -631,11 +647,20 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: or int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS ): continue - subprocess.run( + rerun = subprocess.run( ["gh", "run", "rerun", str(run["id"]), "--repo", LEDGER_REPOSITORY] + ([] if run["conclusion"] == "cancelled" else ["--failed"]), - check=True, + check=False, ) + if rerun.returncode != 0: + # GitHub refuses some re-runs (a run older than 30 days, one + # cancelled before it had jobs); the steps after refresh must + # still run, so this one is reported and the rest go on. + print( + f"::warning::GitHub did not re-run {run['conclusion']} run " + f"{run['id']} ({run.get('path')}, {run['event']}) at {head}" + ) + continue print( f"Re-running {run['conclusion']} run {run['id']} " f"({run.get('path')}, {run['event']}) at {head}" @@ -679,9 +704,11 @@ def cmd_promote(args: argparse.Namespace) -> int: # Only the plan's target may be ready, and only while _promote finds it # on current main: any other ready candidate (left by a run that could # not undo it, or by older code) goes back to draft. + # By number: a branch name alone could match a fork's pull request. returned = [ - _set_ready(branch, False) - for branch in sorted(_ready_candidate_branches() - {plan.ready_branch}) + _set_ready(str(number), False) + for branch, number in sorted(_ready_candidate_branches().items()) + if branch != plan.ready_branch ] safe = True if plan.ready_branch is not None and plan.ready_commit is not None: @@ -693,8 +720,9 @@ def cmd_promote(args: argparse.Namespace) -> int: return 0 if all(returned) and safe else 1 -def _set_ready(branch: str, ready: bool) -> bool: - """Mark a candidate's pull request ready or draft; whether that worked. +def _set_ready(pull_request: str, ready: bool) -> bool: + """Mark a candidate's pull request (a branch or a number) ready or draft; + whether that worked. A draft that stays ready could be merged onto a moved main, so a failed return to draft is reported as an error for the run to fail on. @@ -702,39 +730,25 @@ def _set_ready(branch: str, ready: bool) -> bool: done = ( subprocess.run( - ["gh", "pr", "ready", branch, "--repo", LEDGER_REPOSITORY] + ["gh", "pr", "ready", pull_request, "--repo", LEDGER_REPOSITORY] + ([] if ready else ["--undo"]), check=False, ).returncode == 0 ) if not done and not ready: - print(f"::error::could not return {branch} to draft") + print(f"::error::could not return {pull_request} to draft") return done -def _ready_candidate_branches() -> set[str]: - """Open candidate pull requests that are marked ready for review.""" +def _ready_candidate_branches() -> dict[str, int]: + """Candidate pull requests marked ready for review: branch to number.""" - listing = subprocess.run( - [ - "gh", - "pr", - "list", - "--repo", - LEDGER_REPOSITORY, - "--state", - "open", - "--search", - "head:candidate/uk/", - "--json", - "headRefName,isDraft", - ], - check=True, - capture_output=True, - text=True, - ).stdout - return {item["headRefName"] for item in json.loads(listing) if not item["isDraft"]} + return { + item["headRefName"]: item["number"] + for item in _candidate_pull_requests() + if not item["isDraft"] + } def _promote(branch: str, commit: str, *, automatic: bool) -> bool: @@ -750,8 +764,10 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: parent: if main is anything else at that instant, nothing changes. GitHub then records the pull request as merged. - Returns False only when a return to draft failed, which leaves a ready - pull request that a merge could combine with other code. + Returns False when a return to draft failed, which leaves a ready pull + request that a merge could combine with other code, or when the + candidate waits on runs refresh will not re-run again; either way the + promote run fails so someone sees it. """ fetched = subprocess.run( @@ -773,10 +789,19 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: if not automatic: _set_ready(branch, True) return True - waiting = _unfinished_checks(commit) + waiting, stranded = _unfinished_checks(commit) if waiting: print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) - return True + if stranded: + # Refresh will not re-run these again, and nothing else moves + # the candidate while main stays put: say so in the run's result. + print( + f"::error::{branch} waits on runs no re-run is left for; re-run " + "them by hand: " + "; ".join(stranded) + ) + # A pull request a person or an interrupted run left ready goes back + # to draft until every check on its own tree has passed. + return _set_ready(branch, False) and not stranded _set_ready(branch, True) pushed = subprocess.run( [ @@ -805,8 +830,9 @@ def _git_out(*args: str) -> str: return result.stdout.strip() if result.returncode == 0 else "" -def _unfinished_checks(commit: str) -> list[str]: - """Why the qualified commit's checks do not yet show its own tree passing. +def _unfinished_checks(commit: str) -> tuple[list[str], list[str]]: + """Why the qualified commit's checks do not yet show its own tree passing, + and which of those runs refresh will not re-run again. Pull request runs test the candidate merged with main as it was then; the run of the code-changes workflow that the qualification dispatches on @@ -841,11 +867,11 @@ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: ) runs = listing( f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={commit}&per_page=100", - ".workflow_runs[] | {id, path, event, status, conclusion, check_suite_id}" - " | tojson", + ".workflow_runs[] | {id, path, event, status, conclusion, check_suite_id," + " run_attempt} | tojson", ) if checks is None or runs is None: - return ["the checks could not be read"] + return ["the checks could not be read"], [] newest_runs = _newest_runs(runs) replaced_suites = {run.get("check_suite_id") for run in runs} - { run.get("check_suite_id") for run in newest_runs.values() @@ -876,7 +902,15 @@ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: ) if not checks: problems.append("no checks reported yet") - return sorted(problems) + stranded = sorted( + f"workflow {path} ({event}) is {run.get('conclusion')} after " + f"{run.get('run_attempt')} attempts" + for (path, event), run in newest_runs.items() + if run.get("status") == "completed" + and run.get("conclusion") != "success" + and int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS + ) + return sorted(problems), stranded def cmd_accept(args: argparse.Namespace) -> int: diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index de39d6e89..843d17e10 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -970,7 +970,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "a test failed": ( [*CHECKS_PASSED[:1], _check("Test (3.13)", conclusion="failure", id=4)], @@ -980,7 +980,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "an older cancelled run a newer success replaced": ( [*CHECKS_PASSED, _check("Test (3.13)", conclusion="cancelled", id=0)], @@ -1001,7 +1001,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "no run on the candidate's own tree": ( CHECKS_PASSED, @@ -1011,7 +1011,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "the own-tree run still registering its jobs": ( CHECKS_PASSED[:1], @@ -1021,9 +1021,9 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), - "no checks yet": ([], RUNS_PASSED, True, "parent", 0, True, False, []), + "no checks yet": ([], RUNS_PASSED, True, "parent", 0, True, False, ["undo"]), # Round 3 of #635: a dispatch cancelled while queued has no jobs and so # no checks; the run itself must still block. "the own-tree run cancelled with no jobs": ( @@ -1034,7 +1034,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), # Round 3 of #635: a replaced run re-run later gets newer check ids; its # checks must not count, whatever they conclude. @@ -1075,7 +1075,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "the checks could not be read": ( None, @@ -1085,7 +1085,7 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): 0, True, False, - [], + ["undo"], ), "the branch moved": ( CHECKS_PASSED, @@ -1192,7 +1192,7 @@ def run(argv, **kwargs): monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": automatic}}) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) - monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: set()) + monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: {}) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert [ "undo" if "--undo" in argv else "ready" @@ -1229,6 +1229,25 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( draft; a failed return to draft fails the run so it is seen.""" import argparse + + cli, calls, branch = _sweep_cli( + monkeypatch, path="main moved", failing={"2", "target"} if undo_fails else set() + ) + status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) + undone = [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] + # The stale one by number (the draft and the fork's are left alone), the + # target by _promote (main moved). + assert undone == ["2", branch] + assert status == (1 if undo_fails else 0) + + +def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None): + """The promote CLI over a real `gh pr list` payload: the target (ready), + a stale same-repository candidate (#2, ready), a draft (#3) and a fork's + pull request with the stale branch's name (#4, ready). ``failing`` names + the undos that fail ("2" for the sweep's, "target" for the target's); + ``path`` is "main moved" or "lease rejected".""" + import importlib.util qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) @@ -1242,31 +1261,129 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( cli = importlib.util.module_from_spec(spec) spec.loader.exec_module(cli) calls = [] + listing = [ + { + "number": 1, + "headRefName": branch, + "headRefOid": commit, + "isDraft": False, + "isCrossRepository": False, + }, + { + "number": 2, + "headRefName": stale, + "headRefOid": "s" * 40, + "isDraft": False, + "isCrossRepository": False, + }, + { + "number": 3, + "headRefName": "candidate/uk/draft", + "headRefOid": "d" * 40, + "isDraft": True, + "isCrossRepository": False, + }, + { + "number": 4, + "headRefName": stale, + "headRefOid": "f" * 40, + "isDraft": False, + "isCrossRepository": True, + }, + ] def run(argv, **kwargs): calls.append(argv) out, code = "", 0 - if argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: + if argv[:3] == ["gh", "pr", "list"]: + out = json.dumps(listing) + elif argv[:2] == ["gh", "api"]: + items = ( + (checks or CHECKS_PASSED) + if "check-runs" in argv[2] + else (runs or RUNS_PASSED) + ) + out = "".join(json.dumps(item) + "\n" for item in items) + elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: out = commit elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: out = "p" * 40 elif argv[:2] == ["git", "ls-remote"]: - # Main moved: the target is kept draft too. - out = "m" * 40 + "\trefs/heads/main\n" + main = "m" * 40 if path == "main moved" else "p" * 40 + out = main + "\trefs/heads/main\n" + elif argv[:2] == ["git", "push"]: + code = 1 # the lease is rejected (main moved at the push) elif argv[:3] == ["gh", "pr", "ready"] and "--undo" in argv: - code = 1 if undo_fails else 0 + target = "target" if argv[3] == branch else argv[3] + code = 1 if target in failing else 0 return SimpleNamespace(returncode=code, stdout=out, stderr="") monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) - monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) - monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: {branch, stale}) + return cli, calls, branch + + +@pytest.mark.parametrize("path", ["main moved", "lease rejected"]) +@pytest.mark.parametrize("failing", [set(), {"2"}, {"target"}]) +def test_each_failed_return_to_draft_fails_the_run( + monkeypatch, tmp_path, path, failing +): + """Round 4 of #635: the sweep's failed undo and the target's failed undo + (whether main moved or the lease was rejected) each fail the run alone; + the sweep acts by number on same-repository ready pull requests only.""" + + import argparse + + cli, calls, branch = _sweep_cli(monkeypatch, path=path, failing=failing) + assert cli._ready_candidate_branches() == {branch: 1, "candidate/uk/stale-pair": 2} status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) - undone = [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] - # The stale one by the sweep, the target by _promote (main moved). - assert undone == [stale, branch] - assert status == (1 if undo_fails else 0) + readiness = [ + ("undo" if "--undo" in argv else "ready", argv[3]) + for argv in calls + if argv[:3] == ["gh", "pr", "ready"] + ] + target = ( + [("undo", branch)] + if path == "main moved" + else [("ready", branch), ("undo", branch)] + ) + assert readiness == [("undo", "2"), *target] + assert status == (1 if failing else 0) + + +@pytest.mark.parametrize(("attempt", "stranded"), [(3, False), (4, True)]) +def test_a_candidate_waiting_on_runs_past_the_cap_fails_the_run( + monkeypatch, tmp_path, capsys, attempt, stranded +): + """Round 4 of #635: refresh re-runs a run until MAX_RUN_ATTEMPTS, then + stops; a candidate still waiting on it would otherwise wait unseen, so + the promote run fails with an error naming it. Below the cap it waits + quietly, kept draft.""" + + import argparse + + runs = [ + _run("pull_request", id=10), + { + **_run("workflow_dispatch", conclusion="failure", id=11), + "run_attempt": attempt, + }, + ] + cli, calls, branch = _sweep_cli( + monkeypatch, path="main at the parent", failing=set(), runs=runs + ) + assert cli.MAX_RUN_ATTEMPTS == 4 + status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) + output = capsys.readouterr().out + assert status == (1 if stranded else 0) + assert ("::error::" in output) is stranded + assert [argv for argv in calls if argv[:2] == ["git", "push"]] == [] + # The target waits as a draft (round 4: one a person left ready). + assert [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] == [ + "2", + branch, + ] def test_automatic_promotion_is_on_with_its_basis_recorded(): diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index b613ee4c2..950161ab8 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -339,9 +339,11 @@ def fake_run(argv, **kwargs): stdout=json.dumps( [ { + "number": 1, "headRefName": branch, "headRefOid": state["head"], "isDraft": state["status"] != "ready", + "isCrossRepository": False, } ] ) @@ -486,7 +488,7 @@ def test_catch_up_dispatches_recovery_for_the_original_pair(tmp_path): @pytest.mark.parametrize("head_matches", [False, True]) @pytest.mark.parametrize("verdict", ["qualified", "rejected", "accepted_review"]) def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( - monkeypatch, tmp_path, head_matches, verdict + monkeypatch, tmp_path, capsys, head_matches, verdict ): cli = _cli() receipt = make_receipt( @@ -535,8 +537,11 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( # The run on the candidate's own tree that promotion requires; a # job timeout cancels the run (round 3 of #635). (17, head, code, "workflow_dispatch", "completed", "cancelled", 1), - # Already run as often as the cap allows. - (18, head, docs, "workflow_dispatch", "completed", "failure", 3), + # Already run as often as the cap allows (four attempts). + (18, head, docs, "workflow_dispatch", "completed", "failure", 4), + # Below the cap, but GitHub refuses the re-run (for example a run + # older than 30 days): refresh warns and goes on (round 4). + (19, head, guard, "workflow_dispatch", "completed", "failure", 3), ) ] calls = [] @@ -547,8 +552,8 @@ def fake_run(argv, **kwargs): assert "--paginate" in argv assert f"head_sha={receipt.harness.git_commit}" in argv[2] return SimpleNamespace(stdout="\n".join(json.dumps(run) for run in runs)) - assert argv[:3] == ["gh", "run", "rerun"] and kwargs["check"] is True - return SimpleNamespace(returncode=0) + assert argv[:3] == ["gh", "run", "rerun"] and kwargs["check"] is False + return SimpleNamespace(returncode=1 if argv[3] == "19" else 0) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=fake_run)) args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) @@ -558,8 +563,11 @@ def fake_run(argv, **kwargs): for call in calls if call[:3] == ["gh", "run", "rerun"] } + output = capsys.readouterr().out if receipt.promotable and head_matches: - # Failed jobs only for the timed-out run; the cancelled one in full. - assert reruns == {"16": True, "17": False} + # Failed jobs only for failed and timed-out runs; the cancelled one in + # full; the one at the cap not at all. + assert reruns == {"16": True, "17": False, "19": True} + assert "::warning::GitHub did not re-run failure run 19" in output else: assert calls == [] From cf561a7eaad0d786c76ea1b42f5b4ab02fa903dc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:43:27 -0400 Subject: [PATCH 10/18] Count only the candidate's own runs; strand any run refresh will not re-run Round 5 review of #635: - The runs listed at a commit include other pull requests' runs there, a fork's included. Promotion and refresh now count only runs from this repository on the candidate branch; checks of any other run are ignored. - Stranded now covers every finished, unsuccessful newest run refresh will not re-run: results it does not re-run (action_required, skipped, stale), runs at the attempt cap, and runs past GitHub's 30-day re-run window. - The candidate listing reads up to 1000 pull requests and refuses at the limit; every readiness change and close goes by number. - On the waiting path a failed return to draft fails the run. Tests: fakes project to the jq fields and fill in each run's branch and repository; new cases for fork and other-branch runs, the wider stranded rule, the listing limit, a refused re-run listed first and two candidates at one commit. Co-Authored-By: Claude Opus 5.5 --- changelog.d/634.changed.md | 2 +- scripts/check_uk_candidate.py | 313 +++++++++++++++-------- tests/test_uk_qualification_pipeline.py | 325 +++++++++++++++++++++--- tests/test_uk_qualification_workflow.py | 150 ++++++++--- 4 files changed, 609 insertions(+), 181 deletions(-) diff --git a/changelog.d/634.changed.md b/changelog.d/634.changed.md index c757edcf3..ab888e9d1 100644 --- a/changelog.d/634.changed.md +++ b/changelog.d/634.changed.md @@ -1 +1 @@ -The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. +The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed; runs of other pull requests at the same commit, forks' included, are not counted. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 7418c730d..5642263ac 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -545,32 +545,50 @@ def cmd_requalify(args: argparse.Namespace) -> int: return 0 +#: The most open candidate pull requests the listing reads; gh stops a search +#: at its limit (30 by default), so reaching this one is an error. +PULL_REQUEST_LIMIT = 1000 + + def _candidate_pull_requests() -> list[dict[str, Any]]: """Open candidate pull requests from this repository's own branches. ``head:candidate/uk/`` matches by prefix, and a fork's pull request can - carry the same branch name, so only same-repository ones are candidates. + carry the same branch name, so only same-repository ones are candidates + (a listing without the field counts as a fork's). The search reads up to + ``PULL_REQUEST_LIMIT`` pull requests, forks included, and refuses to go + on if it reaches the limit, since one it did not read could be the + target. """ - listing = subprocess.run( - [ - "gh", - "pr", - "list", - "--repo", - LEDGER_REPOSITORY, - "--state", - "open", - "--search", - "head:candidate/uk/", - "--json", - "number,headRefName,headRefOid,isDraft,isCrossRepository", - ], - check=True, - capture_output=True, - text=True, - ).stdout - return [item for item in json.loads(listing) if not item.get("isCrossRepository")] + listing = json.loads( + subprocess.run( + [ + "gh", + "pr", + "list", + "--repo", + LEDGER_REPOSITORY, + "--state", + "open", + "--search", + "head:candidate/uk/", + "--limit", + str(PULL_REQUEST_LIMIT), + "--json", + "number,headRefName,headRefOid,isDraft,isCrossRepository", + ], + check=True, + capture_output=True, + text=True, + ).stdout + ) + if len(listing) >= PULL_REQUEST_LIMIT: + raise SystemExit( + f"::error::{len(listing)} open pull requests match candidate/uk/; " + "one beyond the listing could be the target" + ) + return [item for item in listing if item.get("isCrossRepository") is False] def _open_candidate_branches() -> dict[str, str]: @@ -601,51 +619,117 @@ def _newest_runs(runs: list[dict[str, Any]]) -> dict[tuple[Any, Any], dict[str, return newest +#: The results refresh re-runs. GitHub re-runs a run only within 30 days of +#: its first attempt. +RERUN_CONCLUSIONS = frozenset({"failure", "timed_out", "cancelled"}) +RERUN_WINDOW = datetime.timedelta(days=30) + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +def _commit_runs(commit: str) -> Optional[list[dict[str, Any]]]: + """Every workflow run GitHub lists at ``commit``; None if unreadable.""" + + result = subprocess.run( + [ + "gh", + "api", + f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={commit}&per_page=100", + "--paginate", + "--jq", + ".workflow_runs[] | {id, head_sha, head_branch," + " head_repository: .head_repository.full_name, path, event, status," + " conclusion, check_suite_id, run_attempt, created_at} | tojson", + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + try: + return [json.loads(line) for line in result.stdout.splitlines() if line] + except json.JSONDecodeError: + return None + + +def _own_runs( + runs: list[dict[str, Any]], commit: str, branch: str +) -> list[dict[str, Any]]: + """The runs of this repository's candidate branch at ``commit``. + + The runs listing for a commit also holds runs of any other pull request + at that commit, a fork's included (round 5 of #635); only runs from this + repository on the candidate branch are the candidate's. + """ + + return [ + run + for run in runs + if run.get("head_sha") == commit + and run.get("head_branch") == branch + and run.get("head_repository") == LEDGER_REPOSITORY + ] + + +def _candidate_runs(commit: str, branch: str) -> Optional[list[dict[str, Any]]]: + """``_own_runs`` of the runs listed at ``commit``; None if unreadable.""" + + runs = _commit_runs(commit) + return None if runs is None else _own_runs(runs, commit, branch) + + +def _rerun_due(run: dict[str, Any], now: datetime.datetime) -> bool: + """Whether refresh re-runs this finished run: it failed, timed out or was + cancelled, has attempts left, and GitHub will still re-run it.""" + + if run.get("status") != "completed" or run.get("conclusion") not in ( + RERUN_CONCLUSIONS + ): + return False + if int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS: + return False + created = run.get("created_at") + if isinstance(created, str) and created: + started = datetime.datetime.fromisoformat(created.replace("Z", "+00:00")) + if now - started > RERUN_WINDOW: + return False + return True + + def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: """Re-run a candidate's unsuccessful newest runs once its receipt is out. The PR workflow does not subscribe to edits or ready_for_review events, and the run of the code-changes workflow on the candidate's own tree is dispatched once, by ``prepare``. So for each workflow and event the - newest run on the candidate commit is re-run when it failed, timed out - or was cancelled (a job timeout cancels the run): a failed or timed-out - one by its failed jobs, a cancelled one in full. Older runs a newer one - replaced are left alone, and no run is re-run past ``MAX_RUN_ATTEMPTS``. - GITHUB_TOKEN may re-run existing runs with actions: write. + newest of the candidate's own runs (``_candidate_runs``) is re-run when + ``_rerun_due``: a failed or timed-out one by its failed jobs, a + cancelled one (a job timeout cancels the run) in full. Older runs a + newer one replaced are left alone. GITHUB_TOKEN may re-run existing runs + with actions: write. """ branches = _open_candidate_branches() + now = _now() for receipt in latest_receipts(Path(args.ledger)): branch = candidate_branch(receipt.pair) head = receipt.harness.git_commit if not receipt.promotable or branches.get(branch) != head: continue - listing = subprocess.run( - [ - "gh", - "api", - f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={head}&per_page=100", - "--paginate", - "--jq", - ".workflow_runs[] | {id, head_sha, path, event, status, conclusion," - " run_attempt} | tojson", - ], - check=True, - capture_output=True, - text=True, - ).stdout + listed = _candidate_runs(head, branch) + if listed is None: + print(f"::warning::could not read the runs of {branch} at {head}") + continue runs = [ run - for run in (json.loads(line) for line in listing.splitlines() if line) - if run["head_sha"] == head - and run["event"] in ("pull_request", "workflow_dispatch") + for run in listed + if run["event"] in ("pull_request", "workflow_dispatch") ] for run in _newest_runs(runs).values(): - if ( - run["status"] != "completed" - or run["conclusion"] not in {"failure", "timed_out", "cancelled"} - or int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS - ): + if not _rerun_due(run, now): continue rerun = subprocess.run( ["gh", "run", "rerun", str(run["id"]), "--repo", LEDGER_REPOSITORY] @@ -675,7 +759,11 @@ def cmd_promote(args: argparse.Namespace) -> int: for problem in problems: # Such a pair is left out of the plan until its files are rederived. print(f"::warning::{problem}") - branches = _open_candidate_branches() if args.apply else {} + pull_requests = _candidate_pull_requests() if args.apply else [] + branches = {pr["headRefName"]: pr["headRefOid"] for pr in pull_requests} + # Every write to a pull request goes by number: gh looks a branch name up + # among a limited listing, which a fork's pull request can shadow. + numbers = {pr["headRefName"]: str(pr["number"]) for pr in pull_requests} plan = plan_promotion(receipts, bundle, branches) print(f"promotion: {plan.reason}") if plan.target is not None: @@ -693,7 +781,7 @@ def cmd_promote(args: argparse.Namespace) -> int: "gh", "pr", "close", - branch, + numbers.get(branch, branch), "--repo", LEDGER_REPOSITORY, "--comment", @@ -704,11 +792,10 @@ def cmd_promote(args: argparse.Namespace) -> int: # Only the plan's target may be ready, and only while _promote finds it # on current main: any other ready candidate (left by a run that could # not undo it, or by older code) goes back to draft. - # By number: a branch name alone could match a fork's pull request. returned = [ - _set_ready(str(number), False) - for branch, number in sorted(_ready_candidate_branches().items()) - if branch != plan.ready_branch + _set_ready(str(pr["number"]), False) + for pr in sorted(pull_requests, key=lambda pr: pr["number"]) + if not pr["isDraft"] and pr["headRefName"] != plan.ready_branch ] safe = True if plan.ready_branch is not None and plan.ready_commit is not None: @@ -716,6 +803,7 @@ def cmd_promote(args: argparse.Namespace) -> int: plan.ready_branch, plan.ready_commit, automatic=config()["promotion"]["automatic"], + pull_request=numbers.get(plan.ready_branch, plan.ready_branch), ) return 0 if all(returned) and safe else 1 @@ -741,17 +829,9 @@ def _set_ready(pull_request: str, ready: bool) -> bool: return done -def _ready_candidate_branches() -> dict[str, int]: - """Candidate pull requests marked ready for review: branch to number.""" - - return { - item["headRefName"]: item["number"] - for item in _candidate_pull_requests() - if not item["isDraft"] - } - - -def _promote(branch: str, commit: str, *, automatic: bool) -> bool: +def _promote( + branch: str, commit: str, *, automatic: bool, pull_request: Optional[str] = None +) -> bool: """Make main exactly the qualified commit, or change nothing. The qualified commit's parent is the main commit it was built on. Only @@ -767,9 +847,13 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: Returns False when a return to draft failed, which leaves a ready pull request that a merge could combine with other code, or when the candidate waits on runs refresh will not re-run again; either way the - promote run fails so someone sees it. + promote run fails so someone sees it. ``pull_request`` names the + candidate's pull request for readiness changes (its number; the branch + by default). """ + target = pull_request or branch + fetched = subprocess.run( ["git", "fetch", "origin", f"refs/heads/{branch}"], check=False, @@ -785,24 +869,25 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: f"not promotable now: {branch} is not the qualified commit " f"{commit[:12]} built on current main; it is requalified on current main" ) - return _set_ready(branch, False) + return _set_ready(target, False) if not automatic: - _set_ready(branch, True) + _set_ready(target, True) return True - waiting, stranded = _unfinished_checks(commit) + waiting, stranded = _unfinished_checks(commit, branch) if waiting: print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) if stranded: # Refresh will not re-run these again, and nothing else moves # the candidate while main stays put: say so in the run's result. print( - f"::error::{branch} waits on runs no re-run is left for; re-run " - "them by hand: " + "; ".join(stranded) + f"::error::{branch} waits on runs refresh will not re-run; re-run " + "or re-dispatch them by hand, or requalify the pair: " + + "; ".join(stranded) ) # A pull request a person or an interrupted run left ready goes back # to draft until every check on its own tree has passed. - return _set_ready(branch, False) and not stranded - _set_ready(branch, True) + return _set_ready(target, False) and not stranded + _set_ready(target, True) pushed = subprocess.run( [ "git", @@ -820,7 +905,7 @@ def _promote(branch: str, commit: str, *, automatic: bool) -> bool: f"automatic promotion skipped: main is no longer {parent[:12]}; " "the pair is requalified on current main" ) - return _set_ready(branch, False) + return _set_ready(target, False) print(f"promoted {branch} at {commit[:12]} to main") return True @@ -830,56 +915,57 @@ def _git_out(*args: str) -> str: return result.stdout.strip() if result.returncode == 0 else "" -def _unfinished_checks(commit: str) -> tuple[list[str], list[str]]: +def _unfinished_checks(commit: str, branch: str) -> tuple[list[str], list[str]]: """Why the qualified commit's checks do not yet show its own tree passing, - and which of those runs refresh will not re-run again. + and which of the runs it waits on refresh will not re-run. Pull request runs test the candidate merged with main as it was then; the run of the code-changes workflow that the qualification dispatches on - the candidate branch tests the commit's own tree, and must exist. For - each workflow and event, the newest run must have finished successfully, - and for each check name the newest check must have finished as success, - skipped or neutral, so an older cancelled or failed execution that a - newer one replaced never blocks. Checks of a run that a newer run of the - same workflow and event replaced are not counted at all, so re-running - it cannot block. A run still registering its jobs, none reported, or an - unreadable list waits. + the candidate branch tests the commit's own tree, and must exist. Only + the candidate's own runs count (``_own_runs``): a run of another pull + request at the same commit, a fork's included, neither blocks nor + passes it. For each workflow and event, the newest own run must have + finished successfully, and for each check name the newest check must + have finished as success, skipped or neutral. Checks of any run that is + not a newest own run (one a newer run replaced, or another pull + request's) are not counted at all; checks outside any workflow run (an + app's) are. A run still registering its jobs, no checks, or an + unreadable list waits. A finished, unsuccessful newest own run that + refresh will not re-run (``_rerun_due``) is stranded. """ - def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: - result = subprocess.run( - ["gh", "api", path, "--paginate", "--jq", jq], - check=False, - capture_output=True, - text=True, - ) - if result.returncode != 0: - return None + result = subprocess.run( + [ + "gh", + "api", + f"repos/{LEDGER_REPOSITORY}/commits/{commit}/check-runs?per_page=100", + "--paginate", + "--jq", + ".check_runs[] | {id, name, status, conclusion," + " check_suite_id: .check_suite.id} | tojson", + ], + check=False, + capture_output=True, + text=True, + ) + checks: Optional[list[dict[str, Any]]] = None + if result.returncode == 0: try: - return [json.loads(line) for line in result.stdout.splitlines() if line] + checks = [json.loads(line) for line in result.stdout.splitlines() if line] except json.JSONDecodeError: - return None - - checks = listing( - f"repos/{LEDGER_REPOSITORY}/commits/{commit}/check-runs?per_page=100", - ".check_runs[] | {id, name, status, conclusion," - " check_suite_id: .check_suite.id} | tojson", - ) - runs = listing( - f"repos/{LEDGER_REPOSITORY}/actions/runs?head_sha={commit}&per_page=100", - ".workflow_runs[] | {id, path, event, status, conclusion, check_suite_id," - " run_attempt} | tojson", - ) + checks = None + runs = _commit_runs(commit) if checks is None or runs is None: return ["the checks could not be read"], [] - newest_runs = _newest_runs(runs) - replaced_suites = {run.get("check_suite_id") for run in runs} - { + newest_runs = _newest_runs(_own_runs(runs, commit, branch)) + excluded_suites = {run.get("check_suite_id") for run in runs} - { run.get("check_suite_id") for run in newest_runs.values() } + counted = [ + check for check in checks if check.get("check_suite_id") not in excluded_suites + ] newest_checks: dict[Any, dict[str, Any]] = {} - for check in checks: - if check.get("check_suite_id") in replaced_suites: - continue + for check in counted: name = check.get("name") if name not in newest_checks or check.get("id", 0) > newest_checks[name].get( "id", 0 @@ -900,15 +986,16 @@ def listing(path: str, jq: str) -> Optional[list[dict[str, Any]]]: problems.append( f"no {CODE_CHANGES_WORKFLOW} run on the candidate's own tree yet" ) - if not checks: + if not counted: problems.append("no checks reported yet") + now = _now() stranded = sorted( f"workflow {path} ({event}) is {run.get('conclusion')} after " - f"{run.get('run_attempt')} attempts" + f"{run.get('run_attempt') or 1} attempt(s)" for (path, event), run in newest_runs.items() if run.get("status") == "completed" and run.get("conclusion") != "success" - and int(run.get("run_attempt") or 1) >= MAX_RUN_ATTEMPTS + and not _rerun_due(run, now) ) return sorted(problems), stranded diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 843d17e10..6fb4f8f87 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -948,6 +948,38 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): ] RUNS_PASSED = [_run("pull_request", id=10), _run("workflow_dispatch", id=11)] + +def _gh_api_output(argv, checks, runs, commit, branch): + """What the fake `gh api` prints: one JSON line per item, each run filled + in as the candidate's own (its commit, branch and this repository) unless + it says otherwise, and every item cut to the fields the call's jq names, + as the real projection does (round 5 of #635: fakes that returned every + field let a dropped field go unnoticed). None for an unreadable list.""" + + items = checks if "check-runs" in argv[2] else runs + if items is None: + return None + jq = argv[argv.index("--jq") + 1] + fields = { + part.split(":")[0].strip() + for part in jq[jq.index("{") + 1 : jq.index("}")].split(",") + } + if "check-runs" not in argv[2]: + items = [ + { + "head_sha": commit, + "head_branch": branch, + "head_repository": "PolicyEngine/policyengine.py", + **item, + } + for item in items + ] + return "".join( + json.dumps({key: value for key, value in item.items() if key in fields}) + "\n" + for item in items + ) + + # Each: checks, runs, branch at the commit, main ("parent" or "moved"), the # push's exit code, automatic; then whether it is promoted, and the readiness # calls made ("ready" or "undo") in order. @@ -1077,6 +1109,74 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): False, ["undo"], ), + # Round 5 of #635: the runs listed at a commit include any other pull + # request's at it, a fork's included. A newer failing run of a fork, or of + # another branch here, and its checks neither block the candidate ... + "a fork's newer failing run at the commit": ( + [ + {**_check("Lint", id=1), "check_suite_id": 100}, + {**_check("Own tree", id=3), "check_suite_id": 110}, + {**_check("Lint", conclusion="failure", id=60), "check_suite_id": 200}, + ], + [ + {**_run("pull_request", id=10), "check_suite_id": 100}, + {**_run("workflow_dispatch", id=11), "check_suite_id": 110}, + { + **_run("pull_request", conclusion="failure", id=20), + "check_suite_id": 200, + "head_repository": "someone/policyengine.py", + }, + ], + True, + "parent", + 0, + True, + True, + ["ready"], + ), + "another branch's newer failing run at the commit": ( + [ + {**_check("Lint", id=1), "check_suite_id": 100}, + {**_check("Own tree", id=3), "check_suite_id": 110}, + {**_check("Lint", conclusion="failure", id=60), "check_suite_id": 200}, + ], + [ + {**_run("pull_request", id=10), "check_suite_id": 100}, + {**_run("workflow_dispatch", id=11), "check_suite_id": 110}, + { + **_run("pull_request", conclusion="failure", id=20), + "check_suite_id": 200, + "head_branch": "candidate/uk/other", + }, + ], + True, + "parent", + 0, + True, + True, + ["ready"], + ), + # ... nor stand in for a run of its own. + "only a fork's run on the candidate's tree": ( + [ + {**_check("Lint", id=1), "check_suite_id": 100}, + {**_check("Own tree", id=3), "check_suite_id": 200}, + ], + [ + {**_run("pull_request", id=10), "check_suite_id": 100}, + { + **_run("workflow_dispatch", id=20), + "check_suite_id": 200, + "head_repository": "someone/policyengine.py", + }, + ], + True, + "parent", + 0, + True, + False, + ["undo"], + ), "the checks could not be read": ( None, RUNS_PASSED, @@ -1173,11 +1273,11 @@ def run(argv, **kwargs): out = "" code = 0 if argv[:2] == ["gh", "api"]: - items = checks if "check-runs" in argv[2] else runs - if items is None: + printed = _gh_api_output(argv, checks, runs, commit, branch) + if printed is None: code = 1 else: - out = "".join(json.dumps(item) + "\n" for item in items) + out = printed elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: out = commit if at_commit else "f" * 40 elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: @@ -1191,8 +1291,19 @@ def run(argv, **kwargs): monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": automatic}}) - monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: commit}) - monkeypatch.setattr(cli, "_ready_candidate_branches", lambda: {}) + monkeypatch.setattr( + cli, + "_candidate_pull_requests", + lambda: [ + { + "number": 1, + "headRefName": branch, + "headRefOid": commit, + "isDraft": True, + "isCrossRepository": False, + } + ], + ) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert [ "undo" if "--undo" in argv else "ready" @@ -1231,22 +1342,24 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( import argparse cli, calls, branch = _sweep_cli( - monkeypatch, path="main moved", failing={"2", "target"} if undo_fails else set() + monkeypatch, path="main moved", failing={"2", "1"} if undo_fails else set() ) status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) undone = [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] - # The stale one by number (the draft and the fork's are left alone), the - # target by _promote (main moved). - assert undone == ["2", branch] + # The stale one (the draft and the fork's are left alone), then the target + # by _promote (main moved), each by number. + assert undone == ["2", "1"] assert status == (1 if undo_fails else 0) -def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None): - """The promote CLI over a real `gh pr list` payload: the target (ready), - a stale same-repository candidate (#2, ready), a draft (#3) and a fork's - pull request with the stale branch's name (#4, ready). ``failing`` names - the undos that fail ("2" for the sweep's, "target" for the target's); - ``path`` is "main moved" or "lease rejected".""" +def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None, push_code=1): + """The promote CLI over a real `gh pr list` payload: the target (#1, + ready), a stale same-repository candidate (#2, ready), a draft (#3), a + fork's pull request with the stale branch's name (#4, ready), a fork's + with the target's branch name at another commit (#5, ready) and a ready + one whose listing lacks the fork field (#6). ``failing`` names the + pull request numbers whose undo fails; ``path`` is "main moved", "lease + rejected" or "main at the parent".""" import importlib.util @@ -1290,20 +1403,32 @@ def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None): "isDraft": False, "isCrossRepository": True, }, + { + "number": 5, + "headRefName": branch, + "headRefOid": "c" * 40, + "isDraft": False, + "isCrossRepository": True, + }, + { + "number": 6, + "headRefName": "candidate/uk/unknown", + "headRefOid": "u" * 40, + "isDraft": False, + }, ] def run(argv, **kwargs): calls.append(argv) out, code = "", 0 if argv[:3] == ["gh", "pr", "list"]: - out = json.dumps(listing) + # gh stops at its limit, 30 unless the call names one. + limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 + out = json.dumps(listing[:limit]) elif argv[:2] == ["gh", "api"]: - items = ( - (checks or CHECKS_PASSED) - if "check-runs" in argv[2] - else (runs or RUNS_PASSED) + out = _gh_api_output( + argv, checks or CHECKS_PASSED, runs or RUNS_PASSED, commit, branch ) - out = "".join(json.dumps(item) + "\n" for item in items) elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: out = commit elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: @@ -1312,10 +1437,9 @@ def run(argv, **kwargs): main = "m" * 40 if path == "main moved" else "p" * 40 out = main + "\trefs/heads/main\n" elif argv[:2] == ["git", "push"]: - code = 1 # the lease is rejected (main moved at the push) + code = push_code # 1: the lease is rejected (main moved at the push) elif argv[:3] == ["gh", "pr", "ready"] and "--undo" in argv: - target = "target" if argv[3] == branch else argv[3] - code = 1 if target in failing else 0 + code = 1 if argv[3] in failing else 0 return SimpleNamespace(returncode=code, stdout=out, stderr="") monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) @@ -1325,18 +1449,22 @@ def run(argv, **kwargs): @pytest.mark.parametrize("path", ["main moved", "lease rejected"]) -@pytest.mark.parametrize("failing", [set(), {"2"}, {"target"}]) +@pytest.mark.parametrize("failing", [set(), {"2"}, {"1"}]) def test_each_failed_return_to_draft_fails_the_run( monkeypatch, tmp_path, path, failing ): """Round 4 of #635: the sweep's failed undo and the target's failed undo - (whether main moved or the lease was rejected) each fail the run alone; - the sweep acts by number on same-repository ready pull requests only.""" + (whether main moved or the lease was rejected) each fail the run alone. + Round 5: every readiness change goes by number, on same-repository pull + requests only; a fork's with the target's branch name neither changes the + target's commit nor is touched, and one whose listing lacks the fork + field counts as a fork's.""" import argparse cli, calls, branch = _sweep_cli(monkeypatch, path=path, failing=failing) - assert cli._ready_candidate_branches() == {branch: 1, "candidate/uk/stale-pair": 2} + assert [pr["number"] for pr in cli._candidate_pull_requests()] == [1, 2, 3] + assert cli._open_candidate_branches()[branch] != "c" * 40 status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) readiness = [ ("undo" if "--undo" in argv else "ready", argv[3]) @@ -1344,46 +1472,169 @@ def test_each_failed_return_to_draft_fails_the_run( if argv[:3] == ["gh", "pr", "ready"] ] target = ( - [("undo", branch)] - if path == "main moved" - else [("ready", branch), ("undo", branch)] + [("undo", "1")] if path == "main moved" else [("ready", "1"), ("undo", "1")] ) assert readiness == [("undo", "2"), *target] assert status == (1 if failing else 0) + listing = next(argv for argv in calls if argv[:3] == ["gh", "pr", "list"]) + assert listing[listing.index("--limit") + 1] == str(cli.PULL_REQUEST_LIMIT) + + +@pytest.mark.parametrize(("listed", "refused"), [(999, False), (1000, True)]) +def test_a_candidate_listing_at_its_limit_is_refused(monkeypatch, listed, refused): + """Round 5 of #635: gh stops a pull request search at its limit, so a + listing that reaches it may have missed the target; promotion refuses to + go on rather than act on part of the list.""" + + import importlib.util + + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_for_test", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + listing = [ + { + "number": number, + "headRefName": f"candidate/uk/pair-{number}", + "headRefOid": "a" * 40, + "isDraft": True, + "isCrossRepository": False, + } + for number in range(1, 1201) + ] + + def run(argv, **kwargs): + limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 + return SimpleNamespace( + returncode=0, stdout=json.dumps(listing[: min(limit, listed)]), stderr="" + ) + + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) + assert cli.PULL_REQUEST_LIMIT == 1000 + if refused: + with pytest.raises(SystemExit, match="::error::1000 open pull requests"): + cli._candidate_pull_requests() + else: + assert len(cli._candidate_pull_requests()) == listed + + +NOW = "2026-10-16T12:00:00Z" +RECENT = "2026-10-16T06:00:00Z" +OLD = "2026-09-15T11:00:00Z" + +# Each: the own-tree run's status, conclusion, attempt and creation time; +# whether the candidate is stranded (waits on a run refresh will not re-run). +STRANDED_CASES = { + "failed, attempts left": ("completed", "failure", 3, RECENT, False), + "failed at the cap": ("completed", "failure", 4, RECENT, True), + "timed out, attempts left": ("completed", "timed_out", 1, RECENT, False), + "cancelled, attempts left": ("completed", "cancelled", 2, RECENT, False), + # Round 5 of #635: refresh re-runs only failed, timed-out and cancelled + # runs, and GitHub only within 30 days, so these wait on nothing. + "waiting on an approval": ("completed", "action_required", 1, RECENT, True), + "skipped": ("completed", "skipped", 1, RECENT, True), + "stale": ("completed", "stale", 1, RECENT, True), + "failed past GitHub's re-run window": ("completed", "failure", 1, OLD, True), + "failed just inside the window": ( + "completed", + "failure", + 1, + "2026-09-16T12:00:01Z", + False, + ), + # Unfinished runs and successes are not stranded at any attempt. + "in progress at the cap": ("in_progress", None, 4, OLD, False), + "queued past the window": ("queued", None, 1, OLD, False), +} -@pytest.mark.parametrize(("attempt", "stranded"), [(3, False), (4, True)]) -def test_a_candidate_waiting_on_runs_past_the_cap_fails_the_run( - monkeypatch, tmp_path, capsys, attempt, stranded +@pytest.mark.parametrize("case", sorted(STRANDED_CASES)) +def test_a_candidate_waiting_on_runs_refresh_will_not_rerun_fails_the_run( + monkeypatch, tmp_path, capsys, case ): """Round 4 of #635: refresh re-runs a run until MAX_RUN_ATTEMPTS, then stops; a candidate still waiting on it would otherwise wait unseen, so - the promote run fails with an error naming it. Below the cap it waits + the promote run fails with an error naming it. Round 5: the same holds + for any finished, unsuccessful run refresh will not re-run (a result it + does not re-run, or one past GitHub's 30-day window). Otherwise it waits quietly, kept draft.""" import argparse + import datetime + status_, conclusion, attempt, created, stranded = STRANDED_CASES[case] runs = [ _run("pull_request", id=10), { - **_run("workflow_dispatch", conclusion="failure", id=11), + **_run("workflow_dispatch", status_, conclusion, id=11), "run_attempt": attempt, + "created_at": created, }, ] cli, calls, branch = _sweep_cli( monkeypatch, path="main at the parent", failing=set(), runs=runs ) + monkeypatch.setattr( + cli, + "_now", + lambda: datetime.datetime.fromisoformat(NOW.replace("Z", "+00:00")), + ) assert cli.MAX_RUN_ATTEMPTS == 4 status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) output = capsys.readouterr().out assert status == (1 if stranded else 0) assert ("::error::" in output) is stranded + if stranded: + assert branch in output and "will not re-run" in output assert [argv for argv in calls if argv[:2] == ["git", "push"]] == [] # The target waits as a draft (round 4: one a person left ready). assert [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] == [ "2", - branch, + "1", + ] + + +def test_a_success_on_the_last_attempt_is_promoted(monkeypatch, tmp_path, capsys): + """Round 5 of #635: a run that passes on its last allowed attempt is not + stranded; the candidate is promoted.""" + + import argparse + + runs = [ + _run("pull_request", id=10), + {**_run("workflow_dispatch", id=11), "run_attempt": 4, "created_at": OLD}, + ] + cli, calls, branch = _sweep_cli( + monkeypatch, path="main at the parent", failing=set(), runs=runs, push_code=0 + ) + assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 + output = capsys.readouterr().out + assert "::error::" not in output and f"promoted {branch}" in output + + +def test_a_failed_return_to_draft_while_waiting_fails_the_run( + monkeypatch, tmp_path, capsys +): + """Round 5 of #635: on the waiting path the target goes back to draft; + if that fails, a ready pull request is left that a merge could combine + with other code, so the run fails (with nothing stranded).""" + + import argparse + + runs = [ + _run("pull_request", id=10), + _run("workflow_dispatch", "in_progress", None, id=11), ] + cli, calls, branch = _sweep_cli( + monkeypatch, path="main at the parent", failing={"1"}, runs=runs + ) + assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 1 + output = capsys.readouterr().out + assert "::error::could not return 1 to draft" in output + assert "will not re-run" not in output + assert [argv for argv in calls if argv[:2] == ["git", "push"]] == [] def test_automatic_promotion_is_on_with_its_basis_recorded(): diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 950161ab8..00f22e0e6 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -510,38 +510,53 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( head = receipt.harness.git_commit if head_matches else "f" * 40 monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [receipt]) monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: head}) - code, docs, guard = ( + monkeypatch.setattr(cli, "_now", lambda: _instant(NOW)) + code, docs, guard, old = ( ".github/workflows/pr_code_changes.yaml", ".github/workflows/pr_docs.yaml", ".github/workflows/guard.yaml", + ".github/workflows/old.yaml", ) + fork = {"head_repository": "someone/policyengine.py"} + other = {"head_branch": "candidate/uk/other"} runs = [ - { - "id": identifier, - "head_sha": sha, - "path": path, - "event": event, - "status": status, - "conclusion": conclusion, - "run_attempt": attempt, - } - for identifier, sha, path, event, status, conclusion, attempt in ( + _listed_run(identifier, sha, branch, path, event, status, conclusion, attempt) + | extra + for identifier, sha, path, event, status, conclusion, attempt, extra in ( # Replaced by run 16 of the same workflow and event: left alone, # so its checks cannot come back newer (round 3 of #635). - (11, head, code, "pull_request", "completed", "failure", 1), - (12, head, docs, "pull_request", "completed", "success", 1), - (13, head, guard, "pull_request", "in_progress", None, 1), - (14, "a" * 40, code, "pull_request", "completed", "failure", 1), - (15, head, code, "push", "completed", "failure", 1), - (16, head, code, "pull_request", "completed", "timed_out", 1), + (11, head, code, "pull_request", "completed", "failure", 1, {}), + (12, head, docs, "pull_request", "completed", "success", 1, {}), + (13, head, guard, "pull_request", "in_progress", None, 1, {}), + (14, "a" * 40, code, "pull_request", "completed", "failure", 1, {}), + (15, head, code, "push", "completed", "failure", 1, {}), + # GitHub refuses this re-run: refresh warns and the rest still go + # (round 4; round 5 lists it first). + (16, head, code, "pull_request", "completed", "timed_out", 1, {}), # The run on the candidate's own tree that promotion requires; a # job timeout cancels the run (round 3 of #635). - (17, head, code, "workflow_dispatch", "completed", "cancelled", 1), + (17, head, code, "workflow_dispatch", "completed", "cancelled", 1, {}), # Already run as often as the cap allows (four attempts). - (18, head, docs, "workflow_dispatch", "completed", "failure", 4), - # Below the cap, but GitHub refuses the re-run (for example a run - # older than 30 days): refresh warns and goes on (round 4). - (19, head, guard, "workflow_dispatch", "completed", "failure", 3), + (18, head, docs, "workflow_dispatch", "completed", "failure", 4, {}), + (19, head, guard, "workflow_dispatch", "completed", "failure", 3, {}), + # Round 5 of #635: a fork's newer run at the same commit, and one + # of another branch here, are not the candidate's: neither is + # re-run, nor replaces the candidate's own runs 16 and 12. + (21, head, code, "pull_request", "completed", "failure", 1, fork), + (22, head, docs, "pull_request", "completed", "failure", 1, other), + # Round 5: GitHub re-runs only within 30 days of the first attempt, + # and refresh re-runs only failures, timeouts and cancellations. + ( + 23, + head, + old, + "workflow_dispatch", + "completed", + "failure", + 1, + {"created_at": OLD}, + ), + (24, head, old, "pull_request", "completed", "action_required", 1, {}), ) ] calls = [] @@ -551,23 +566,98 @@ def fake_run(argv, **kwargs): if argv[:2] == ["gh", "api"]: assert "--paginate" in argv assert f"head_sha={receipt.harness.git_commit}" in argv[2] - return SimpleNamespace(stdout="\n".join(json.dumps(run) for run in runs)) + return SimpleNamespace( + returncode=0, stdout="\n".join(json.dumps(run) for run in runs) + ) assert argv[:3] == ["gh", "run", "rerun"] and kwargs["check"] is False - return SimpleNamespace(returncode=1 if argv[3] == "19" else 0) + return SimpleNamespace(returncode=1 if argv[3] == "16" else 0) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=fake_run)) args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) assert args.func(args) == 0 - reruns = { - call[3]: "--failed" in call + reruns = [ + (call[3], "--failed" in call) for call in calls if call[:3] == ["gh", "run", "rerun"] - } + ] output = capsys.readouterr().out if receipt.promotable and head_matches: # Failed jobs only for failed and timed-out runs; the cancelled one in - # full; the one at the cap not at all. - assert reruns == {"16": True, "17": False, "19": True} - assert "::warning::GitHub did not re-run failure run 19" in output + # full; the one at the cap, the old one and the fork's not at all. + assert reruns == [("16", True), ("17", False), ("19", True)] + assert "::warning::GitHub did not re-run timed_out run 16" in output else: assert calls == [] + + +NOW = "2026-10-16T12:00:00Z" +OLD = "2026-09-15T11:00:00Z" + + +def _instant(text): + import datetime + + return datetime.datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def _listed_run(identifier, sha, branch, path, event, status, conclusion, attempt): + """A run as `_commit_runs` reads it: the candidate's own by default.""" + + return { + "id": identifier, + "head_sha": sha, + "head_branch": branch, + "head_repository": "PolicyEngine/policyengine.py", + "path": path, + "event": event, + "status": status, + "conclusion": conclusion, + "check_suite_id": 1000 + identifier, + "run_attempt": attempt, + "created_at": "2026-10-16T06:00:00Z", + } + + +def test_refresh_reruns_each_candidate_only_its_own_runs(monkeypatch, tmp_path, capsys): + """Round 5 of #635: two candidates qualified at the same commit share its + runs listing; each re-runs only its own branch's runs, and one whose runs + cannot be read is reported while the other is still refreshed.""" + + cli = _cli() + first = make_receipt(pair=make_pair(model="2.200.0")) + second = make_receipt(pair=make_pair(model="2.201.0")) + head = first.harness.git_commit + assert second.harness.git_commit == head + branches = {candidate_branch(r.pair): head for r in (first, second)} + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [first, second]) + monkeypatch.setattr(cli, "_open_candidate_branches", lambda: branches) + monkeypatch.setattr(cli, "_now", lambda: _instant(NOW)) + code = ".github/workflows/pr_code_changes.yaml" + first_branch, second_branch = branches + runs = [ + _listed_run( + 31, head, first_branch, code, "pull_request", "completed", "failure", 1 + ), + _listed_run( + 32, head, second_branch, code, "pull_request", "completed", "failure", 1 + ), + ] + calls = [] + unreadable = {"once": True} + + def fake_run(argv, **kwargs): + calls.append(argv) + if argv[:2] == ["gh", "api"]: + if unreadable.pop("once", False): + return SimpleNamespace(returncode=1, stdout="") + return SimpleNamespace( + returncode=0, stdout="\n".join(json.dumps(run) for run in runs) + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=fake_run)) + args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) + assert args.func(args) == 0 + output = capsys.readouterr().out + assert f"::warning::could not read the runs of {first_branch}" in output + assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] From 53c35358a036f91c8341551d26c86d7eda7c7c14 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:01:29 -0400 Subject: [PATCH 11/18] Pin the listing's fields and a capped success while waiting; document stranding - The gh pr list fake returns only the --json fields asked for, so a call that stops asking for isCrossRepository fails a test. - A run that passed on its last allowed attempt is not stranded while the candidate still waits on another run. - The qualification runbook says when the promote job fails for a stranded candidate and the three ways to clear it. All 22 mutants of the round-5 change (the round-5 survivors among them) fail the promotion suites. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/runbooks/uk-qualification.md | 25 ++++++++++++ tests/test_uk_qualification_pipeline.py | 38 ++++++++++++++----- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index bc04b9b3b..fdd2481e2 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -49,3 +49,28 @@ some from fewer than 10 records, so never commit, paste or publish it. 3. Read `failing[...]["detail"]` locally. Report only the finding (which condition failed, on which route), never the figures. + +## A candidate stranded before automatic promotion + +Automatic promotion moves main to a qualified candidate only once the newest +run of each workflow and event on the candidate branch, from this repository, +has passed, including a `pr_code_changes.yaml` run dispatched on the branch +itself. Runs of other pull requests at the same commit, forks' included, do +not count. The `refresh-pr-checks` step re-runs a failed, timed-out or +cancelled run until it has had four attempts, within GitHub's 30 days of its +first attempt. + +When the candidate waits on a run that refresh will not re-run, the promote +job fails with `::error:: waits on runs refresh will not re-run`, +naming each run, its result and its attempts. That covers a run at four +attempts, one older than 30 days, and one whose result refresh never re-runs +(`action_required`, `skipped`, `stale`, `startup_failure` and the like). The +job fails on every scheduled run until one of these clears it: + +- Fix the cause, then re-run the named run by hand + (`gh run rerun --repo PolicyEngine/policyengine.py`); a passing + newest attempt clears it. +- For the run on the candidate's own tree, dispatch a new one: + `gh workflow run pr_code_changes.yaml --repo PolicyEngine/policyengine.py --ref `. +- Requalify the pair: run `certify-uk-release` with its versions, `pair_id` + and `force` set. diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 6fb4f8f87..51f97d9f3 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -1352,6 +1352,20 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( assert status == (1 if undo_fails else 0) +def _gh_pr_list_output(argv, listing): + """What the fake `gh pr list` prints: gh stops at its limit (30 unless + the call names one) and returns only the fields ``--json`` names, so a + field the call stops asking for is missing, as it would be.""" + + limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 + fields = set(argv[argv.index("--json") + 1].split(",")) + return json.dumps( + [{key: value for key, value in pr.items() if key in fields} for pr in listing][ + :limit + ] + ) + + def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None, push_code=1): """The promote CLI over a real `gh pr list` payload: the target (#1, ready), a stale same-repository candidate (#2, ready), a draft (#3), a @@ -1422,9 +1436,7 @@ def run(argv, **kwargs): calls.append(argv) out, code = "", 0 if argv[:3] == ["gh", "pr", "list"]: - # gh stops at its limit, 30 unless the call names one. - limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 - out = json.dumps(listing[:limit]) + out = _gh_pr_list_output(argv, listing) elif argv[:2] == ["gh", "api"]: out = _gh_api_output( argv, checks or CHECKS_PASSED, runs or RUNS_PASSED, commit, branch @@ -1506,9 +1518,8 @@ def test_a_candidate_listing_at_its_limit_is_refused(monkeypatch, listed, refuse ] def run(argv, **kwargs): - limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 return SimpleNamespace( - returncode=0, stdout=json.dumps(listing[: min(limit, listed)]), stderr="" + returncode=0, stdout=_gh_pr_list_output(argv, listing[:listed]), stderr="" ) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) @@ -1596,14 +1607,20 @@ def test_a_candidate_waiting_on_runs_refresh_will_not_rerun_fails_the_run( ] -def test_a_success_on_the_last_attempt_is_promoted(monkeypatch, tmp_path, capsys): +@pytest.mark.parametrize("other_waiting", [False, True]) +def test_a_success_on_the_last_attempt_is_not_stranded( + monkeypatch, tmp_path, capsys, other_waiting +): """Round 5 of #635: a run that passes on its last allowed attempt is not - stranded; the candidate is promoted.""" + stranded, whether the candidate then promotes or still waits on another + run.""" import argparse runs = [ - _run("pull_request", id=10), + _run("pull_request", "in_progress", None, id=10) + if other_waiting + else _run("pull_request", id=10), {**_run("workflow_dispatch", id=11), "run_attempt": 4, "created_at": OLD}, ] cli, calls, branch = _sweep_cli( @@ -1611,7 +1628,10 @@ def test_a_success_on_the_last_attempt_is_promoted(monkeypatch, tmp_path, capsys ) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 output = capsys.readouterr().out - assert "::error::" not in output and f"promoted {branch}" in output + assert "::error::" not in output + assert (f"promoted {branch}" in output) is not other_waiting + if other_waiting: + assert f"automatic promotion waits for {branch}" in output def test_a_failed_return_to_draft_while_waiting_fails_the_run( From 7bc1cb1b33687e26cecf959ab01c7425b35a1560 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:34:41 -0400 Subject: [PATCH 12/18] Refuse a candidate whose branch has another pull request; list only PRs to main Round 6 of #635: - M1: a pull request from the candidate branch to another base runs the same workflows with the same commit, branch and repository as the candidate's own, and GitHub's runs do not record which pull request a run is for (the API's pull_requests field lists open pull requests matching the head, not the trigger). Promotion now lists the branch's pull requests in every state; while one to another base is open, or after one was closed or merged at the commit, the candidate is kept draft and the run fails with an error naming it (close it and requalify: the new candidate commit leaves its runs behind). Refresh re-runs none of that candidate's runs. An unreadable or full listing waits. - M2: the candidate listing asks for pull requests to main and drops any other, so two pull requests from one branch cannot collapse into one entry and send a readiness change to the wrong one. - The workflow's own lookups (prepare's return to draft, opening or editing the candidate pull request, the verdict edit) now find this repository's pull request to main and write by number. - Coverage the reviewer found missing: the gh fakes run the code's jq with the real jq over API-shaped data (repository and check suite as objects), superseded candidates are closed by number, and both edges of the 30-day re-run window are pinned. - The changelog and runbook say what is isolated and what is refused. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 26 +- changelog.d/634.changed.md | 2 +- docs/engineering/runbooks/uk-qualification.md | 19 +- scripts/check_uk_candidate.py | 144 ++++- tests/qualification_fixtures.py | 20 + tests/test_uk_qualification_pipeline.py | 548 ++++++++++++++++-- tests/test_uk_qualification_workflow.py | 104 +++- 7 files changed, 767 insertions(+), 96 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 3abfae736..1055dde1b 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -234,8 +234,11 @@ jobs: run: | # A forced attempt may replace an already qualified head. Revoke # its ready status before exposing any new, unqualified commit. - ready_pr=$(gh pr list --head "$BRANCH" --state open --json number,isDraft \ - --jq '.[] | select(.isDraft == false) | .number') + # Only this repository's pull request to main is the candidate's: + # a fork's can carry the same branch name. + ready_pr=$(gh pr list --head "$BRANCH" --base main --state open \ + --json number,isDraft,isCrossRepository \ + --jq '.[] | select(.isDraft == false and .isCrossRepository == false) | .number') if [[ -n "$ready_pr" ]]; then gh pr ready "$ready_pr" --undo fi @@ -275,8 +278,13 @@ jobs: Ledger: https://github.com/${GITHUB_REPOSITORY}/tree/${LEDGER_BRANCH}/uk/${PAIR_ID} 🤖 Generated with [Claude Code](https://claude.com/claude-code)" - if gh pr view "$BRANCH" --json number >/dev/null 2>&1; then - gh pr edit "$BRANCH" --body "$body" + # By number: gh looks a branch name up among pull requests a fork's + # can shadow. + number=$(gh pr list --head "$BRANCH" --base main --state open \ + --json number,isCrossRepository \ + --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') + if [[ -n "$number" ]]; then + gh pr edit "$number" --body "$body" else gh pr create --draft --base main --head "$BRANCH" --title "$title" --body "$body" fi @@ -391,7 +399,15 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} BRANCH: ${{ needs.resolve.outputs.branch }} - run: gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/pr-body.md" + run: | + number=$(gh pr list --head "$BRANCH" --base main --state open \ + --json number,isCrossRepository \ + --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') + if [[ -z "$number" ]]; then + echo "::error::no open pull request from $BRANCH to main" + exit 1 + fi + gh pr edit "$number" --body-file "$RUNNER_TEMP/pr-body.md" catch-up: if: github.event_name == 'schedule' diff --git a/changelog.d/634.changed.md b/changelog.d/634.changed.md index ab888e9d1..a29f29fb7 100644 --- a/changelog.d/634.changed.md +++ b/changelog.d/634.changed.md @@ -1 +1 @@ -The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed; runs of other pull requests at the same commit, forks' included, are not counted. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. +The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed. Runs of pull requests from other branches or forks at the same commit are not counted; a candidate whose own branch has another pull request (to another base) that ran on its commit is refused with an error, since GitHub does not record which pull request a run is for. Every write to a candidate pull request goes by the number of this repository's pull request to main. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index fdd2481e2..a94060fcf 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -55,8 +55,8 @@ some from fewer than 10 records, so never commit, paste or publish it. Automatic promotion moves main to a qualified candidate only once the newest run of each workflow and event on the candidate branch, from this repository, has passed, including a `pr_code_changes.yaml` run dispatched on the branch -itself. Runs of other pull requests at the same commit, forks' included, do -not count. The `refresh-pr-checks` step re-runs a failed, timed-out or +itself. Runs of pull requests from other branches or forks at the same +commit do not count. The `refresh-pr-checks` step re-runs a failed, timed-out or cancelled run until it has had four attempts, within GitHub's 30 days of its first attempt. @@ -69,8 +69,21 @@ job fails on every scheduled run until one of these clears it: - Fix the cause, then re-run the named run by hand (`gh run rerun --repo PolicyEngine/policyengine.py`); a passing - newest attempt clears it. + newest attempt clears it. GitHub refuses this for a run whose first + attempt is more than 30 days old. - For the run on the candidate's own tree, dispatch a new one: `gh workflow run pr_code_changes.yaml --repo PolicyEngine/policyengine.py --ref `. - Requalify the pair: run `certify-uk-release` with its versions, `pair_id` and `force` set. + +### Another pull request from the candidate branch + +A pull request from a candidate branch to a base other than main runs the +same workflows with the same commit and branch as the candidate's own pull +request, and GitHub's run records do not say which pull request a run is +for. While such a pull request is open, or after one was closed or merged +at the candidate's commit, the promote job fails with +`::error:: is not promoted: pull requests ... from it run on the same commit`, +and refresh re-runs none of the candidate's runs. Close that pull request, +then requalify the pair as above: the new candidate commit leaves the other +pull request's runs behind. diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 5642263ac..6fe52e0a2 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -551,13 +551,15 @@ def cmd_requalify(args: argparse.Namespace) -> int: def _candidate_pull_requests() -> list[dict[str, Any]]: - """Open candidate pull requests from this repository's own branches. + """Open candidate pull requests to main from this repository's own branches. ``head:candidate/uk/`` matches by prefix, and a fork's pull request can carry the same branch name, so only same-repository ones are candidates - (a listing without the field counts as a fork's). The search reads up to - ``PULL_REQUEST_LIMIT`` pull requests, forks included, and refuses to go - on if it reaches the limit, since one it did not read could be the + (a listing without the field counts as a fork's). Only pull requests to + main are (round 6 of #635): GitHub allows one open pull request per head + and base, so each candidate branch then has at most one. The search reads + up to ``PULL_REQUEST_LIMIT`` pull requests, forks included, and refuses + to go on if it reaches the limit, since one it did not read could be the target. """ @@ -571,12 +573,14 @@ def _candidate_pull_requests() -> list[dict[str, Any]]: LEDGER_REPOSITORY, "--state", "open", + "--base", + "main", "--search", "head:candidate/uk/", "--limit", str(PULL_REQUEST_LIMIT), "--json", - "number,headRefName,headRefOid,isDraft,isCrossRepository", + "number,baseRefName,headRefName,headRefOid,isDraft,isCrossRepository", ], check=True, capture_output=True, @@ -588,15 +592,68 @@ def _candidate_pull_requests() -> list[dict[str, Any]]: f"::error::{len(listing)} open pull requests match candidate/uk/; " "one beyond the listing could be the target" ) - return [item for item in listing if item.get("isCrossRepository") is False] + return [ + item + for item in listing + if item.get("isCrossRepository") is False and item.get("baseRefName") == "main" + ] -def _open_candidate_branches() -> dict[str, str]: - """Open candidate branches and the commit each one's pull request is at.""" +#: The most pull requests from one candidate branch the check below reads. +SAME_BRANCH_LIMIT = 100 - return { - item["headRefName"]: item["headRefOid"] for item in _candidate_pull_requests() - } + +def _shared_branch_pull_requests(branch: str, commit: str) -> Optional[list[str]]: + """Pull requests from this repository's ``branch`` to a base other than + main whose runs can be at ``commit``; None if they cannot all be read. + + Such a pull request runs the same workflows with the same commit, branch + and repository as the candidate's own, to main (round 6 of #635), and + GitHub's run records do not say which pull request started a run. So + while one is open, or after one was closed or merged at this commit, + the candidate's own runs cannot be told from its runs. (Pull requests + from the branch to main are the candidate's own: at most one is open, + and a closed one's runs test the same merge.) A listing without the fork + field counts as this repository's, and one that reaches + ``SAME_BRANCH_LIMIT`` as unreadable. + """ + + result = subprocess.run( + [ + "gh", + "pr", + "list", + "--repo", + LEDGER_REPOSITORY, + "--head", + branch, + "--state", + "all", + "--limit", + str(SAME_BRANCH_LIMIT), + "--json", + "number,baseRefName,headRefOid,isCrossRepository,state", + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + try: + listing = json.loads(result.stdout) + except json.JSONDecodeError: + return None + if not isinstance(listing, list) or len(listing) >= SAME_BRANCH_LIMIT: + return None + return sorted( + f"#{pr.get('number')} (to {pr.get('baseRefName')}, " + f"{str(pr.get('state')).lower()})" + for pr in listing + if pr.get("isCrossRepository") is not True + and pr.get("baseRefName") != "main" + and (pr.get("state") == "OPEN" or pr.get("headRefOid") == commit) + ) #: A run is re-run until it has made this many attempts in all, so a genuine @@ -662,7 +719,10 @@ def _own_runs( The runs listing for a commit also holds runs of any other pull request at that commit, a fork's included (round 5 of #635); only runs from this - repository on the candidate branch are the candidate's. + repository on the candidate branch are the candidate's. Runs of another + pull request from the candidate branch itself look the same, so refresh + and promotion first check there is none + (``_shared_branch_pull_requests``). """ return [ @@ -708,16 +768,34 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: newest of the candidate's own runs (``_candidate_runs``) is re-run when ``_rerun_due``: a failed or timed-out one by its failed jobs, a cancelled one (a job timeout cancels the run) in full. Older runs a - newer one replaced are left alone. GITHUB_TOKEN may re-run existing runs - with actions: write. + newer one replaced are left alone, and so is every run of a candidate + whose runs another pull request from its branch shares + (``_shared_branch_pull_requests``): promotion refuses it anyway, and + re-runs would spend the other pull request's attempts. GITHUB_TOKEN may + re-run existing runs with actions: write. """ - branches = _open_candidate_branches() + pull_requests = {pr["headRefName"]: pr for pr in _candidate_pull_requests()} now = _now() for receipt in latest_receipts(Path(args.ledger)): branch = candidate_branch(receipt.pair) head = receipt.harness.git_commit - if not receipt.promotable or branches.get(branch) != head: + pull_request = pull_requests.get(branch) + if ( + not receipt.promotable + or pull_request is None + or pull_request["headRefOid"] != head + ): + continue + shared = _shared_branch_pull_requests(branch, head) + if shared is None: + print(f"::warning::could not read the pull requests from {branch}") + continue + if shared: + print( + f"::warning::not re-running {branch}'s runs: pull requests " + f"{', '.join(shared)} from it share them" + ) continue listed = _candidate_runs(head, branch) if listed is None: @@ -845,9 +923,10 @@ def _promote( GitHub then records the pull request as merged. Returns False when a return to draft failed, which leaves a ready pull - request that a merge could combine with other code, or when the - candidate waits on runs refresh will not re-run again; either way the - promote run fails so someone sees it. ``pull_request`` names the + request that a merge could combine with other code, when the candidate + waits on runs refresh will not re-run again, or when another pull + request from its branch shares its runs; either way the promote run + fails so someone sees it. ``pull_request`` names the candidate's pull request for readiness changes (its number; the branch by default). """ @@ -873,6 +952,25 @@ def _promote( if not automatic: _set_ready(target, True) return True + shared = _shared_branch_pull_requests(branch, commit) + if shared is None: + print( + f"automatic promotion waits for {branch}: the pull requests from it " + "could not be read" + ) + return _set_ready(target, False) + if shared: + # Their runs and the candidate's cannot be told apart, and they stay + # at this commit after the pull requests close: only a new candidate + # commit leaves them behind. + print( + f"::error::{branch} is not promoted: pull requests " + f"{', '.join(shared)} from it run on the same commit, and GitHub " + "does not record which pull request a run is for; close them and " + "requalify the pair" + ) + _set_ready(target, False) + return False waiting, stranded = _unfinished_checks(commit, branch) if waiting: print(f"automatic promotion waits for {branch}: " + "; ".join(waiting)) @@ -922,9 +1020,11 @@ def _unfinished_checks(commit: str, branch: str) -> tuple[list[str], list[str]]: Pull request runs test the candidate merged with main as it was then; the run of the code-changes workflow that the qualification dispatches on the candidate branch tests the commit's own tree, and must exist. Only - the candidate's own runs count (``_own_runs``): a run of another pull - request at the same commit, a fork's included, neither blocks nor - passes it. For each workflow and event, the newest own run must have + the candidate's own runs count (``_own_runs``): a run of a pull request + from another branch or a fork at the same commit neither blocks nor + passes it (``_promote`` has already refused a candidate whose branch has + another pull request sharing its runs). For each workflow and event, + the newest own run must have finished successfully, and for each check name the newest check must have finished as success, skipped or neutral. Checks of any run that is not a newest own run (one a newer run replaced, or another pull diff --git a/tests/qualification_fixtures.py b/tests/qualification_fixtures.py index 5f27153bf..4e4d265ac 100644 --- a/tests/qualification_fixtures.py +++ b/tests/qualification_fixtures.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import shutil +import subprocess from pathlib import Path from policyengine.provenance.qualification import ( @@ -98,5 +100,23 @@ def make_receipt( ) +def jq(expression: str, data) -> str: + """Apply ``expression`` to ``data`` with the real jq, printing strings + raw as gh's ``--jq`` does, so fakes of gh evaluate the code's own jq + rather than guess at it (round 6 of #635: a fake that picked fields by + name let a wrong source path pass). GitHub's ubuntu runners ship jq; a + machine without it fails these tests rather than skipping them.""" + + binary = shutil.which("jq") + assert binary, "jq is required to evaluate the code's jq expressions" + return subprocess.run( + [binary, "-r", expression], + input=json.dumps(data), + capture_output=True, + text=True, + check=True, + ).stdout + + def golden(name: str) -> dict: return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 51f97d9f3..b0c00d083 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -52,6 +52,7 @@ VENDORED_SCHEMAS, gates, golden, + jq, make_pair, make_receipt, sha, @@ -950,34 +951,74 @@ def _run(event, status="completed", conclusion="success", id=1, path=CODE): def _gh_api_output(argv, checks, runs, commit, branch): - """What the fake `gh api` prints: one JSON line per item, each run filled - in as the candidate's own (its commit, branch and this repository) unless - it says otherwise, and every item cut to the fields the call's jq names, - as the real projection does (round 5 of #635: fakes that returned every - field let a dropped field go unnoticed). None for an unreadable list.""" - - items = checks if "check-runs" in argv[2] else runs - if items is None: - return None - jq = argv[argv.index("--jq") + 1] - fields = { - part.split(":")[0].strip() - for part in jq[jq.index("{") + 1 : jq.index("}")].split(",") - } - if "check-runs" not in argv[2]: - items = [ - { - "head_sha": commit, - "head_branch": branch, - "head_repository": "PolicyEngine/policyengine.py", - **item, - } - for item in items - ] - return "".join( - json.dumps({key: value for key, value in item.items() if key in fields}) + "\n" - for item in items - ) + """What the fake `gh api` prints: the call's own jq applied by the real + jq to an API-shaped page. Each run is the candidate's own (its commit, + branch and this repository) unless it says otherwise, with its repository + as the API's object; each check's suite id sits under `check_suite`, as + in the API (rounds 5 and 6 of #635: fakes that returned every field, or + picked fields by name, let a dropped field or a wrong source path pass). + None for an unreadable list.""" + + if "check-runs" in argv[2]: + if checks is None: + return None + page = { + "check_runs": [ + { + **{k: v for k, v in check.items() if k != "check_suite_id"}, + "check_suite": {"id": check.get("check_suite_id")}, + } + for check in checks + ] + } + else: + if runs is None: + return None + page = { + "workflow_runs": [ + { + "head_sha": commit, + "head_branch": branch, + **run, + "head_repository": { + "full_name": run.get( + "head_repository", "PolicyEngine/policyengine.py" + ) + }, + } + for run in runs + ] + } + return jq(argv[argv.index("--jq") + 1], page) + + +def _gh_pr_list_output(argv, listing): + """What the fake `gh pr list` prints, filtering as gh does: by state + (open unless the call says), base, head branch, a `head:` search prefix, + then the limit (30 unless the call names one); each pull request cut to + the `--json` fields, and the call's `--jq` applied by the real jq. A + listing item is open and to main unless it says otherwise.""" + + def option(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + + state = option("--state", "open") + base = option("--base") + head = option("--head") + search = option("--search", "") + prefix = search.removeprefix("head:") if search.startswith("head:") else None + fields = set(option("--json").split(",")) + matched = [ + pr + for pr in ({"state": "OPEN", "baseRefName": "main", **pr} for pr in listing) + if (state == "all" or pr["state"] == state.upper()) + and (base is None or pr["baseRefName"] == base) + and (head is None or pr["headRefName"] == head) + and (prefix is None or pr["headRefName"].startswith(prefix)) + ][: int(option("--limit", "30"))] + printed = [{k: v for k, v in pr.items() if k in fields} for pr in matched] + expression = option("--jq") + return jq(expression, printed) if expression else json.dumps(printed) # Each: checks, runs, branch at the commit, main ("parent" or "moved"), the @@ -1268,6 +1309,14 @@ def test_promotion_makes_main_exactly_the_qualified_commit( spec.loader.exec_module(cli) calls = [] + target = { + "number": 1, + "headRefName": branch, + "headRefOid": commit, + "isDraft": True, + "isCrossRepository": False, + } + def run(argv, **kwargs): calls.append(argv) out = "" @@ -1278,6 +1327,8 @@ def run(argv, **kwargs): code = 1 else: out = printed + elif argv[:3] == ["gh", "pr", "list"]: + out = _gh_pr_list_output(argv, [target]) elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: out = commit if at_commit else "f" * 40 elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: @@ -1291,19 +1342,7 @@ def run(argv, **kwargs): monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": automatic}}) - monkeypatch.setattr( - cli, - "_candidate_pull_requests", - lambda: [ - { - "number": 1, - "headRefName": branch, - "headRefOid": commit, - "isDraft": True, - "isCrossRepository": False, - } - ], - ) + monkeypatch.setattr(cli, "_candidate_pull_requests", lambda: [target]) assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 assert [ "undo" if "--undo" in argv else "ready" @@ -1352,34 +1391,41 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( assert status == (1 if undo_fails else 0) -def _gh_pr_list_output(argv, listing): - """What the fake `gh pr list` prints: gh stops at its limit (30 unless - the call names one) and returns only the fields ``--json`` names, so a - field the call stops asking for is missing, as it would be.""" - - limit = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else 30 - fields = set(argv[argv.index("--json") + 1].split(",")) - return json.dumps( - [{key: value for key, value in pr.items() if key in fields} for pr in listing][ - :limit - ] - ) +SUPERSEDED_PAIR = make_pair(model="2.0.0", data="1.0.0", core="3.0.0") -def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None, push_code=1): +def _sweep_cli( + monkeypatch, + *, + path, + failing, + checks=None, + runs=None, + push_code=1, + extra=(), + superseded=False, + shared_list_fails=False, +): """The promote CLI over a real `gh pr list` payload: the target (#1, ready), a stale same-repository candidate (#2, ready), a draft (#3), a fork's pull request with the stale branch's name (#4, ready), a fork's with the target's branch name at another commit (#5, ready) and a ready - one whose listing lacks the fork field (#6). ``failing`` names the - pull request numbers whose undo fails; ``path`` is "main moved", "lease - rejected" or "main at the parent".""" + one whose listing lacks the fork field (#6), then ``extra`` (each may + use "{branch}" and "{commit}" for the target's). ``superseded`` adds a + receipt for an older pair with an open pull request (#9), which the plan + closes. ``failing`` names the pull request numbers whose undo fails; + ``path`` is "main moved", "lease rejected" or "main at the parent"; + ``shared_list_fails`` fails the listing of the target branch's pull + requests.""" import importlib.util qualified = _promotion_receipt(pair=_promotable_pair_and_branch()) branch = candidate_branch(qualified.pair) commit = qualified.harness.git_commit + receipts = [qualified] + if superseded: + receipts.append(_promotion_receipt(pair=SUPERSEDED_PAIR)) stale = "candidate/uk/stale-pair" spec = importlib.util.spec_from_file_location( "check_uk_candidate_for_test", @@ -1430,13 +1476,35 @@ def _sweep_cli(monkeypatch, *, path, failing, checks=None, runs=None, push_code= "headRefOid": "u" * 40, "isDraft": False, }, + *( + { + key: value.format(branch=branch, commit=commit) + if isinstance(value, str) + else value + for key, value in pr.items() + } + for pr in extra + ), ] + if superseded: + listing.append( + { + "number": 9, + "headRefName": candidate_branch(SUPERSEDED_PAIR), + "headRefOid": "o" * 40, + "isDraft": True, + "isCrossRepository": False, + } + ) def run(argv, **kwargs): calls.append(argv) out, code = "", 0 if argv[:3] == ["gh", "pr", "list"]: - out = _gh_pr_list_output(argv, listing) + if shared_list_fails and "--head" in argv: + code = 1 + else: + out = _gh_pr_list_output(argv, listing) elif argv[:2] == ["gh", "api"]: out = _gh_api_output( argv, checks or CHECKS_PASSED, runs or RUNS_PASSED, commit, branch @@ -1454,7 +1522,7 @@ def run(argv, **kwargs): code = 1 if argv[3] in failing else 0 return SimpleNamespace(returncode=code, stdout=out, stderr="") - monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [qualified]) + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: receipts) monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) monkeypatch.setattr(cli, "config", lambda: {"promotion": {"automatic": True}}) return cli, calls, branch @@ -1475,8 +1543,11 @@ def test_each_failed_return_to_draft_fails_the_run( import argparse cli, calls, branch = _sweep_cli(monkeypatch, path=path, failing=failing) - assert [pr["number"] for pr in cli._candidate_pull_requests()] == [1, 2, 3] - assert cli._open_candidate_branches()[branch] != "c" * 40 + candidates = cli._candidate_pull_requests() + assert [pr["number"] for pr in candidates] == [1, 2, 3] + assert {pr["headRefName"]: pr["headRefOid"] for pr in candidates}[branch] != ( + "c" * 40 + ) status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) readiness = [ ("undo" if "--undo" in argv else "ready", argv[3]) @@ -1531,6 +1602,181 @@ def run(argv, **kwargs): assert len(cli._candidate_pull_requests()) == listed +OTHER_BASE = { + "number": 7, + "headRefName": "{branch}", + "headRefOid": "{commit}", + "baseRefName": "release", + "isDraft": False, + "isCrossRepository": False, +} + +# Each: another pull request from the target's branch (over OTHER_BASE); +# whether promotion refuses the target because its runs are shared. +SHARED_BRANCH_CASES = { + "open, to another base": ({}, True), + "open, to another base, its head not yet updated": ( + {"headRefOid": "a" * 40}, + True, + ), + "closed at the commit, to another base": ({"state": "CLOSED"}, True), + "merged at the commit, to another base": ({"state": "MERGED"}, True), + "open, to another base, without the fork field": ( + {"isCrossRepository": None}, + True, + ), + "closed at another commit, to another base": ( + {"state": "CLOSED", "headRefOid": "a" * 40}, + False, + ), + "a fork's, open, to another base": ({"isCrossRepository": True}, False), + "closed at the commit, to main": ( + {"state": "CLOSED", "baseRefName": "main"}, + False, + ), +} + + +@pytest.mark.parametrize("case", sorted(SHARED_BRANCH_CASES)) +def test_a_candidate_whose_branch_has_another_pull_request_is_refused( + monkeypatch, tmp_path, capsys, case +): + """Round 6 of #635: a pull request from the candidate branch to another + base runs the same workflows with the same commit, branch and repository, + and GitHub's runs do not say which pull request they are for, so its + failure could strand the candidate and its success hide the candidate's + failure. While one is open, or after one closed at this commit, the + candidate is kept draft and the run fails with an error naming it; the + other pull request is never readied, undone or counted as a candidate.""" + + import argparse + + overrides, refused = SHARED_BRANCH_CASES[case] + other = {**OTHER_BASE, **overrides} + if other["isCrossRepository"] is None: + del other["isCrossRepository"] + cli, calls, branch = _sweep_cli( + monkeypatch, + path="main at the parent", + failing=set(), + push_code=0, + extra=[other], + ) + assert 7 not in [pr["number"] for pr in cli._candidate_pull_requests()] + status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) + output = capsys.readouterr().out + pushes = [argv for argv in calls if argv[:2] == ["git", "push"]] + readiness = [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] + assert "7" not in readiness + if refused: + assert status == 1 and not pushes + assert f"::error::{branch} is not promoted" in output and "#7" in output + assert readiness == ["2", "1"] + assert not [argv for argv in calls if argv[:2] == ["gh", "api"]] + else: + assert status == 0 and len(pushes) == 1 + assert f"promoted {branch}" in output + + +@pytest.mark.parametrize("how", ["the listing fails", "the listing is full"]) +def test_unreadable_pull_requests_from_the_candidate_branch_wait( + monkeypatch, tmp_path, capsys, how +): + """Round 6 of #635: when the candidate branch's pull requests cannot all + be read (the call fails, or returns SAME_BRANCH_LIMIT of them), the + candidate waits as a draft without reading its runs.""" + + import argparse + + extra = ( + [ + { + "number": 100 + n, + "headRefName": "{branch}", + "headRefOid": "a" * 40, + "state": "CLOSED", + "isDraft": True, + "isCrossRepository": False, + } + for n in range(99) + ] + if how == "the listing is full" + else [] + ) + cli, calls, branch = _sweep_cli( + monkeypatch, + path="main at the parent", + failing=set(), + push_code=0, + extra=extra, + shared_list_fails=how == "the listing fails", + ) + assert cli.SAME_BRANCH_LIMIT == 100 + assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 + output = capsys.readouterr().out + assert "could not be read" in output and "::error::" not in output + assert not [argv for argv in calls if argv[:2] == ["git", "push"]] + assert not [argv for argv in calls if argv[:2] == ["gh", "api"]] + assert [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] == [ + "2", + "1", + ] + + +def test_only_pull_requests_to_main_are_candidates(monkeypatch): + """Round 6 of #635: the listing asks gh for pull requests to main and + drops any other it returns, so two pull requests from one candidate + branch cannot collapse into one entry that sends readiness changes to + the wrong one.""" + + import importlib.util + + spec = importlib.util.spec_from_file_location( + "check_uk_candidate_for_test", + Path(__file__).resolve().parents[1] / "scripts" / "check_uk_candidate.py", + ) + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + calls = [] + listing = [ + { + "number": number, + "baseRefName": base, + "headRefName": "candidate/uk/2.200.0-1.58.9", + "headRefOid": "a" * 40, + "isDraft": False, + "isCrossRepository": False, + } + for number, base in ((1, "release"), (2, "main")) + ] + + def run(argv, **kwargs): + calls.append(argv) + # A gh that ignored --base: the script still keeps only main's. + return SimpleNamespace(returncode=0, stdout=json.dumps(listing), stderr="") + + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=run)) + assert [pr["number"] for pr in cli._candidate_pull_requests()] == [2] + assert calls[0][calls[0].index("--base") + 1] == "main" + + +def test_superseded_candidates_are_closed_by_number(monkeypatch, tmp_path): + """Round 6 of #635: closing a superseded candidate goes by its number, + like every other write to a pull request.""" + + import argparse + + cli, calls, branch = _sweep_cli( + monkeypatch, + path="main at the parent", + failing=set(), + push_code=0, + superseded=True, + ) + assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 + assert [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "close"]] == ["9"] + + NOW = "2026-10-16T12:00:00Z" RECENT = "2026-10-16T06:00:00Z" OLD = "2026-09-15T11:00:00Z" @@ -1555,6 +1801,22 @@ def run(argv, **kwargs): "2026-09-16T12:00:01Z", False, ), + # Round 6: GitHub re-runs a run exactly 30 days old, and not one a + # second older. + "failed exactly 30 days ago": ( + "completed", + "failure", + 1, + "2026-09-16T12:00:00Z", + False, + ), + "failed 30 days and a second ago": ( + "completed", + "failure", + 1, + "2026-09-16T11:59:59Z", + True, + ), # Unfinished runs and successes are not stranded at any attempt. "in progress at the cap": ("in_progress", None, 4, OLD, False), "queued past the window": ("queued", None, 1, OLD, False), @@ -1845,6 +2107,140 @@ def test_a_forced_rerun_stages_a_new_attempt(tmp_path): assert attempt_key(pair, tmp_path, base_commit=base, force=True) == f"{base[:12]}-3" +#: Source for the fake gh's `pr list`, embedded in the workflow shell tests: +#: filters the listing by --base, --head (the BRANCH environment variable's +#: branch unless an item names another) and --state as gh does, cuts each +#: item to the --json fields and applies the call's --jq with the real jq. +FAKE_GH_PR_LIST = """ +import shutil +import subprocess + + +def pr_list(args, listing): + def option(name, default=None): + return args[args.index(name) + 1] if name in args else default + + base, head, state = option("--base"), option("--head"), option("--state", "open") + rows = [ + {"headRefName": os.environ["BRANCH"], "state": "OPEN", "isDraft": True, **pr} + for pr in listing + ] + rows = [ + pr + for pr in rows + if (base is None or pr["baseRefName"] == base) + and (head is None or pr["headRefName"] == head) + and (state == "all" or pr["state"] == state.upper()) + ] + fields = option("--json").split(",") + rows = [{k: pr[k] for k in fields if k in pr} for pr in rows] + expression = option("--jq") + if expression is None: + print(json.dumps(rows)) + return + sys.stdout.write( + subprocess.run( + [shutil.which("jq"), "-r", expression], + input=json.dumps(rows), + capture_output=True, + text=True, + check=True, + ).stdout + ) +""" + + +@pytest.mark.parametrize("step_name", ["open", "verdict"]) +@pytest.mark.parametrize("exists", [True, False]) +def test_the_workflow_writes_to_the_candidates_own_pull_request_by_number( + tmp_path, step_name, exists +): + """Round 6 of #635: the steps that open or update the candidate's pull + request find it among this repository's pull requests to main and write + by number; a fork's pull request with the branch's name, or this + repository's from the branch to another base, is never edited. With no + candidate pull request, the prepare step opens one to main and the + verdict step fails.""" + + import yaml + + assert shutil.which("jq"), "jq is required to run the workflow's jq" + workflow = yaml.safe_load( + (REPO_ROOT / ".github/workflows/certify-uk-release.yaml").read_text() + ) + job, name = { + "open": ("prepare", "Open or update the draft candidate PR"), + "verdict": ("replay", "Update the candidate PR with the verdict"), + }[step_name] + step = next(s for s in workflow["jobs"][job]["steps"] if s.get("name") == name) + log_path = tmp_path / "commands.jsonl" + listing = [ + {"number": 575, "baseRefName": "main", "isCrossRepository": True}, + {"number": 576, "baseRefName": "release", "isCrossRepository": False}, + { + "number": 577, + "baseRefName": "main", + "isCrossRepository": False, + "state": "CLOSED", + }, + ] + if exists: + listing.insert( + 1, {"number": 574, "baseRefName": "main", "isCrossRepository": False} + ) + fake = f"""#!{sys.executable} +import json +import os +import sys +from pathlib import Path + +{FAKE_GH_PR_LIST} +args = sys.argv[1:] +with Path(os.environ["COMMAND_LOG"]).open("a") as log: + log.write(json.dumps(["gh", *args]) + "\\n") +if args[:2] == ["pr", "list"]: + pr_list(args, {listing!r}) +""" + binary_dir = tmp_path / "bin" + binary_dir.mkdir() + (binary_dir / "gh").write_text(fake) + (binary_dir / "gh").chmod(0o755) + branch = "candidate/uk/2.126.8-1.58.0" + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", step["run"]], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{binary_dir}{os.pathsep}{os.environ['PATH']}", + "BRANCH": branch, + "PAIR_ID": "a" * 64, + "GITHUB_REPOSITORY": "PolicyEngine/policyengine.py", + "GITHUB_RUN_ID": "1", + "LEDGER_BRANCH": "uk-qualification-ledger", + "RUNNER_TEMP": str(tmp_path), + "COMMAND_LOG": str(log_path), + }, + capture_output=True, + text=True, + timeout=30, + ) + commands = [json.loads(line) for line in log_path.read_text().splitlines()] + writes = [c for c in commands if c[1:3] in (["pr", "edit"], ["pr", "create"])] + if exists: + assert result.returncode == 0, result.stdout + result.stderr + assert [c[:4] for c in writes] == [["gh", "pr", "edit", "574"]] + elif step_name == "open": + assert result.returncode == 0, result.stdout + result.stderr + assert len(writes) == 1 and writes[0][1:3] == ["pr", "create"] + create = writes[0] + assert create[create.index("--base") + 1] == "main" + assert create[create.index("--head") + 1] == branch + else: + assert result.returncode == 1 + assert f"no open pull request from {branch} to main" in result.stdout + assert writes == [] + + @pytest.mark.parametrize( ("pr_state", "reset_fails"), [("ready", False), ("draft", False), ("absent", False), ("ready", True)], @@ -1873,6 +2269,7 @@ def test_the_workflow_drafts_existing_prs_before_replacing_their_head( import sys from pathlib import Path +{FAKE_GH_PR_LIST} command = Path(sys.argv[0]).name args = sys.argv[1:] state_path = Path(os.environ["PR_STATE"]) @@ -1880,8 +2277,33 @@ def test_the_workflow_drafts_existing_prs_before_replacing_their_head( with Path(os.environ["COMMAND_LOG"]).open("a") as log: log.write(json.dumps([command, *args]) + "\\n") if command == "gh" and args[:2] == ["pr", "list"]: - if state["status"] == "ready": - print("574") + # A fork's ready pull request with the branch's name, and one of this + # repository's from the branch to another base, are never undone. + listing = [ + {{ + "number": 575, + "baseRefName": "main", + "isCrossRepository": True, + "isDraft": False, + }}, + {{ + "number": 576, + "baseRefName": "release", + "isCrossRepository": False, + "isDraft": False, + }}, + ] + if state["status"] != "absent": + listing.insert( + 0, + {{ + "number": 574, + "baseRefName": "main", + "isCrossRepository": False, + "isDraft": state["status"] != "ready", + }}, + ) + pr_list(args, listing) elif command == "gh" and args[:2] == ["pr", "ready"]: assert args[2:] == ["574", "--undo"] if os.environ["RESET_FAILS"] == "1": diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 00f22e0e6..1c0289623 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -344,6 +344,7 @@ def fake_run(argv, **kwargs): "headRefOid": state["head"], "isDraft": state["status"] != "ready", "isCrossRepository": False, + "baseRefName": "main", } ] ) @@ -509,7 +510,9 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( branch = candidate_branch(receipt.pair) head = receipt.harness.git_commit if head_matches else "f" * 40 monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [receipt]) - monkeypatch.setattr(cli, "_open_candidate_branches", lambda: {branch: head}) + monkeypatch.setattr( + cli, "_candidate_pull_requests", lambda: [_candidate_pr(1, branch, head)] + ) monkeypatch.setattr(cli, "_now", lambda: _instant(NOW)) code, docs, guard, old = ( ".github/workflows/pr_code_changes.yaml", @@ -563,6 +566,11 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( def fake_run(argv, **kwargs): calls.append(argv) + if argv[:3] == ["gh", "pr", "list"]: + assert argv[argv.index("--head") + 1] == branch + return SimpleNamespace( + returncode=0, stdout=json.dumps([_candidate_pr(1, branch, head)]) + ) if argv[:2] == ["gh", "api"]: assert "--paginate" in argv assert f"head_sha={receipt.harness.git_commit}" in argv[2] @@ -594,6 +602,22 @@ def fake_run(argv, **kwargs): OLD = "2026-09-15T11:00:00Z" +def _candidate_pr(number, branch, head, **fields): + """A same-repository pull request from ``branch`` to main, open, as + `gh pr list` reads it.""" + + return { + "number": number, + "baseRefName": "main", + "headRefName": branch, + "headRefOid": head, + "isDraft": True, + "isCrossRepository": False, + "state": "OPEN", + **fields, + } + + def _instant(text): import datetime @@ -629,8 +653,12 @@ def test_refresh_reruns_each_candidate_only_its_own_runs(monkeypatch, tmp_path, head = first.harness.git_commit assert second.harness.git_commit == head branches = {candidate_branch(r.pair): head for r in (first, second)} + pull_requests = [ + _candidate_pr(number, name, head) + for number, name in enumerate(branches, start=1) + ] monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [first, second]) - monkeypatch.setattr(cli, "_open_candidate_branches", lambda: branches) + monkeypatch.setattr(cli, "_candidate_pull_requests", lambda: pull_requests) monkeypatch.setattr(cli, "_now", lambda: _instant(NOW)) code = ".github/workflows/pr_code_changes.yaml" first_branch, second_branch = branches @@ -647,6 +675,14 @@ def test_refresh_reruns_each_candidate_only_its_own_runs(monkeypatch, tmp_path, def fake_run(argv, **kwargs): calls.append(argv) + if argv[:3] == ["gh", "pr", "list"]: + head_branch = argv[argv.index("--head") + 1] + return SimpleNamespace( + returncode=0, + stdout=json.dumps( + [pr for pr in pull_requests if pr["headRefName"] == head_branch] + ), + ) if argv[:2] == ["gh", "api"]: if unreadable.pop("once", False): return SimpleNamespace(returncode=1, stdout="") @@ -661,3 +697,67 @@ def fake_run(argv, **kwargs): output = capsys.readouterr().out assert f"::warning::could not read the runs of {first_branch}" in output assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] + + +@pytest.mark.parametrize("shared", ["another base", "unreadable"]) +def test_refresh_leaves_a_candidate_whose_branch_runs_are_shared( + monkeypatch, tmp_path, capsys, shared +): + """Round 6 of #635: a pull request from the candidate branch to another + base runs on the same commit and branch, so the candidate's runs cannot + be told from its runs; refresh re-runs none of them (promotion refuses + the candidate anyway), and the next candidate is still refreshed. The + same holds while that branch's pull requests cannot be read.""" + + cli = _cli() + first = make_receipt(pair=make_pair(model="2.200.0")) + second = make_receipt(pair=make_pair(model="2.201.0")) + head = first.harness.git_commit + first_branch, second_branch = (candidate_branch(r.pair) for r in (first, second)) + pull_requests = [ + _candidate_pr(1, first_branch, head), + _candidate_pr(2, second_branch, head), + ] + other = _candidate_pr(7, first_branch, head, baseRefName="release") + monkeypatch.setattr(cli, "latest_receipts", lambda root, **_: [first, second]) + monkeypatch.setattr(cli, "_candidate_pull_requests", lambda: pull_requests) + monkeypatch.setattr(cli, "_now", lambda: _instant(NOW)) + code = ".github/workflows/pr_code_changes.yaml" + runs = [ + _listed_run( + 31, head, first_branch, code, "pull_request", "completed", "failure", 1 + ), + _listed_run( + 32, head, second_branch, code, "pull_request", "completed", "failure", 1 + ), + ] + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + if argv[:3] == ["gh", "pr", "list"]: + head_branch = argv[argv.index("--head") + 1] + if head_branch == first_branch and shared == "unreadable": + return SimpleNamespace(returncode=1, stdout="") + listing = [pr for pr in pull_requests if pr["headRefName"] == head_branch] + if head_branch == first_branch: + listing.append(other) + return SimpleNamespace(returncode=0, stdout=json.dumps(listing)) + if argv[:2] == ["gh", "api"]: + return SimpleNamespace( + returncode=0, stdout="\n".join(json.dumps(run) for run in runs) + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli, "subprocess", SimpleNamespace(run=fake_run)) + args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) + assert args.func(args) == 0 + output = capsys.readouterr().out + if shared == "unreadable": + assert f"::warning::could not read the pull requests from {first_branch}" in ( + output + ) + else: + assert f"::warning::not re-running {first_branch}'s runs" in output + assert "#7 (to release, open)" in output + assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] From fc6f1b11342fd59125058be69e6ab89c25ada51d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 08:07:37 -0400 Subject: [PATCH 13/18] Find candidate PRs by owner-qualified head; refuse runs after a base change Round 7 of #635: - M1: gh pr list --head matches the branch name in forks too and stops at 30, so newer forks' pull requests with the candidate branch's name could crowd the candidate's own out of the listing, and prepare would push a replacement head without returning it to draft. The workflow's lookups (prepare's return to draft, opening or editing the candidate pull request, the verdict edit) and the shared-branch check now use GitHub's REST listing filtered by head=:, which holds only this repository's pull requests, paginated in full. - M2: a base change does not change what a pull request's earlier runs tested. Among pull requests from the branch whose runs can be at the commit, one now to main whose base was ever changed (GraphQL BaseRefChangedEvent; the candidate's own included) counts as sharing the candidate's runs, so promotion refuses it and refresh leaves its runs alone; an unreadable history waits. - Tests: the REST and GraphQL fakes run the code's jq with the real jq over API-shaped responses; forty newer forks' pull requests no longer hide the candidate's in prepare or the shared-branch check; base-change cases for the candidate's own and closed pull requests. All 10 mutants of this change fail the suites. - Changelog and runbook describe the base-change rule. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/certify-uk-release.yaml | 23 +- changelog.d/634.changed.md | 2 +- docs/engineering/runbooks/uk-qualification.md | 17 +- scripts/check_uk_candidate.py | 148 ++++++---- tests/qualification_fixtures.py | 74 +++++ tests/test_uk_qualification_pipeline.py | 271 +++++++++++------- tests/test_uk_qualification_workflow.py | 79 +++-- 7 files changed, 421 insertions(+), 193 deletions(-) diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 1055dde1b..23f1c04e4 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -235,10 +235,11 @@ jobs: # A forced attempt may replace an already qualified head. Revoke # its ready status before exposing any new, unqualified commit. # Only this repository's pull request to main is the candidate's: - # a fork's can carry the same branch name. - ready_pr=$(gh pr list --head "$BRANCH" --base main --state open \ - --json number,isDraft,isCrossRepository \ - --jq '.[] | select(.isDraft == false and .isCrossRepository == false) | .number') + # head=: lists only this repository's, so a fork's + # with the same branch name can neither shadow nor crowd it out. + ready_pr=$(gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" \ + -f head="${GITHUB_REPOSITORY%%/*}:$BRANCH" -f base=main -f state=open \ + --paginate --jq '.[] | select(.draft == false) | .number') if [[ -n "$ready_pr" ]]; then gh pr ready "$ready_pr" --undo fi @@ -279,10 +280,10 @@ jobs: 🤖 Generated with [Claude Code](https://claude.com/claude-code)" # By number: gh looks a branch name up among pull requests a fork's - # can shadow. - number=$(gh pr list --head "$BRANCH" --base main --state open \ - --json number,isCrossRepository \ - --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') + # can shadow. head=: lists only this repository's. + number=$(gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" \ + -f head="${GITHUB_REPOSITORY%%/*}:$BRANCH" -f base=main -f state=open \ + --paginate --jq '.[].number') if [[ -n "$number" ]]; then gh pr edit "$number" --body "$body" else @@ -400,9 +401,9 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} BRANCH: ${{ needs.resolve.outputs.branch }} run: | - number=$(gh pr list --head "$BRANCH" --base main --state open \ - --json number,isCrossRepository \ - --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') + number=$(gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" \ + -f head="${GITHUB_REPOSITORY%%/*}:$BRANCH" -f base=main -f state=open \ + --paginate --jq '.[].number') if [[ -z "$number" ]]; then echo "::error::no open pull request from $BRANCH to main" exit 1 diff --git a/changelog.d/634.changed.md b/changelog.d/634.changed.md index a29f29fb7..eed801260 100644 --- a/changelog.d/634.changed.md +++ b/changelog.d/634.changed.md @@ -1 +1 @@ -The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed. Runs of pull requests from other branches or forks at the same commit are not counted; a candidate whose own branch has another pull request (to another base) that ran on its commit is refused with an error, since GitHub does not record which pull request a run is for. Every write to a candidate pull request goes by the number of this repository's pull request to main. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. +The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed. Runs of pull requests from other branches or forks at the same commit are not counted; a candidate whose runs may have tested another base (its branch has a pull request to another base that ran on its commit, or one now to main whose base was ever changed) is refused with an error, since GitHub does not record which pull request or base a run is for. Candidate pull requests are found through GitHub's listing of this repository's own branches, which forks' pull requests cannot crowd out, and every write to one goes by its number. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index 656ed67c3..d3268f3f2 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -91,10 +91,13 @@ job fails on every scheduled run until one of these clears it: A pull request from a candidate branch to a base other than main runs the same workflows with the same commit and branch as the candidate's own pull -request, and GitHub's run records do not say which pull request a run is -for. While such a pull request is open, or after one was closed or merged -at the candidate's commit, the promote job fails with -`::error:: is not promoted: pull requests ... from it run on the same commit`, -and refresh re-runs none of the candidate's runs. Close that pull request, -then requalify the pair as above: the new candidate commit leaves the other -pull request's runs behind. +request, and GitHub's run records do not say which pull request, or which +base, a run is for. While such a pull request is open, or after one was +closed or merged at the candidate's commit, or when a pull request from the +branch now to main ever had another base (the candidate's own included), +the promote job fails with +`::error:: is not promoted: runs at its commit may have tested another base`, +naming the pull requests, and refresh re-runs none of the candidate's runs. +Close those pull requests, then requalify the pair as above: the new +candidate commit leaves their runs behind, and prepare opens a new pull +request to main if none is open. diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 6fe52e0a2..fd0914f8a 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -599,40 +599,84 @@ def _candidate_pull_requests() -> list[dict[str, Any]]: ] -#: The most pull requests from one candidate branch the check below reads. -SAME_BRANCH_LIMIT = 100 +#: Whether a pull request's base was ever changed: GraphQL records each change +#: as a BaseRefChangedEvent (its timeline's totalCount ignores the type +#: filter, so the nodes are read). +BASE_CHANGES_QUERY = ( + "query($owner: String!, $name: String!, $number: Int!) { repository(owner:" + " $owner, name: $name) { pullRequest(number: $number) { timelineItems(" + "itemTypes: [BASE_REF_CHANGED_EVENT], first: 1) { nodes { __typename }" + " pageInfo { hasNextPage } } } } }" +) + + +def _base_changed(number: Any) -> Optional[bool]: + """Whether pull request ``number``'s base was ever changed; None if that + cannot be read.""" + + owner, name = LEDGER_REPOSITORY.split("/") + result = subprocess.run( + [ + "gh", + "api", + "graphql", + "-f", + f"query={BASE_CHANGES_QUERY}", + "-F", + f"owner={owner}", + "-F", + f"name={name}", + "-F", + f"number={number}", + "--jq", + ".data.repository.pullRequest.timelineItems" + " | (.nodes | length > 0) or .pageInfo.hasNextPage", + ], + check=False, + capture_output=True, + text=True, + ) + answer = result.stdout.strip() if result.returncode == 0 else "" + return {"true": True, "false": False}.get(answer) def _shared_branch_pull_requests(branch: str, commit: str) -> Optional[list[str]]: - """Pull requests from this repository's ``branch`` to a base other than - main whose runs can be at ``commit``; None if they cannot all be read. - - Such a pull request runs the same workflows with the same commit, branch - and repository as the candidate's own, to main (round 6 of #635), and - GitHub's run records do not say which pull request started a run. So - while one is open, or after one was closed or merged at this commit, - the candidate's own runs cannot be told from its runs. (Pull requests - from the branch to main are the candidate's own: at most one is open, - and a closed one's runs test the same merge.) A listing without the fork - field counts as this repository's, and one that reaches - ``SAME_BRANCH_LIMIT`` as unreadable. + """Pull requests from this repository's ``branch`` whose runs at + ``commit`` may have tested a base other than main; None if they cannot + all be read. + + A pull request from the candidate branch to another base runs the same + workflows with the same commit, branch and repository as the + candidate's own (round 6 of #635), and GitHub's run records do not say + which pull request started a run, nor which base it tested. So these + count: one open to another base, one closed or merged at this commit to + another base, and, among those to main, one whose base was ever changed, + since a base change does not change what its earlier runs tested + (round 7). Every other pull request from the branch to main is the + candidate's own: at most one is open, and a closed one's runs test the + same merge. The REST listing filtered by ``head=:`` holds + only this repository's pull requests, all of them (paginated), so a + fork's with the same branch name can neither shadow nor crowd them out. """ + owner = LEDGER_REPOSITORY.split("/")[0] result = subprocess.run( [ "gh", - "pr", - "list", - "--repo", - LEDGER_REPOSITORY, - "--head", - branch, - "--state", - "all", - "--limit", - str(SAME_BRANCH_LIMIT), - "--json", - "number,baseRefName,headRefOid,isCrossRepository,state", + "api", + "-X", + "GET", + f"repos/{LEDGER_REPOSITORY}/pulls", + "-f", + f"head={owner}:{branch}", + "-f", + "state=all", + "-f", + "per_page=100", + "--paginate", + "--jq", + ".[] | {number, state, merged: (.merged_at != null), base: .base.ref," + " head_sha: .head.sha} | tojson", ], check=False, capture_output=True, @@ -641,19 +685,23 @@ def _shared_branch_pull_requests(branch: str, commit: str) -> Optional[list[str] if result.returncode != 0: return None try: - listing = json.loads(result.stdout) + listing = [json.loads(line) for line in result.stdout.splitlines() if line] except json.JSONDecodeError: return None - if not isinstance(listing, list) or len(listing) >= SAME_BRANCH_LIMIT: - return None - return sorted( - f"#{pr.get('number')} (to {pr.get('baseRefName')}, " - f"{str(pr.get('state')).lower()})" - for pr in listing - if pr.get("isCrossRepository") is not True - and pr.get("baseRefName") != "main" - and (pr.get("state") == "OPEN" or pr.get("headRefOid") == commit) - ) + shared = [] + for pr in listing: + if pr.get("state") != "open" and pr.get("head_sha") != commit: + continue + state = "merged" if pr.get("merged") else str(pr.get("state")) + if pr.get("base") != "main": + shared.append(f"#{pr.get('number')} (to {pr.get('base')}, {state})") + continue + changed = _base_changed(pr.get("number")) + if changed is None: + return None + if changed: + shared.append(f"#{pr.get('number')} (to main after a base change, {state})") + return sorted(shared) #: A run is re-run until it has made this many attempts in all, so a genuine @@ -769,9 +817,9 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: ``_rerun_due``: a failed or timed-out one by its failed jobs, a cancelled one (a job timeout cancels the run) in full. Older runs a newer one replaced are left alone, and so is every run of a candidate - whose runs another pull request from its branch shares - (``_shared_branch_pull_requests``): promotion refuses it anyway, and - re-runs would spend the other pull request's attempts. GITHUB_TOKEN may + whose runs may have tested another base (``_shared_branch_pull_requests``): + promotion refuses it anyway, and re-runs would retest that base and + spend the attempts. GITHUB_TOKEN may re-run existing runs with actions: write. """ @@ -793,8 +841,8 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: continue if shared: print( - f"::warning::not re-running {branch}'s runs: pull requests " - f"{', '.join(shared)} from it share them" + f"::warning::not re-running {branch}'s runs: they may have " + f"tested another base, from pull requests {', '.join(shared)}" ) continue listed = _candidate_runs(head, branch) @@ -924,8 +972,9 @@ def _promote( Returns False when a return to draft failed, which leaves a ready pull request that a merge could combine with other code, when the candidate - waits on runs refresh will not re-run again, or when another pull - request from its branch shares its runs; either way the promote run + waits on runs refresh will not re-run again, or when its runs may have + tested another base (``_shared_branch_pull_requests``); either way the + promote run fails so someone sees it. ``pull_request`` names the candidate's pull request for readiness changes (its number; the branch by default). @@ -962,12 +1011,13 @@ def _promote( if shared: # Their runs and the candidate's cannot be told apart, and they stay # at this commit after the pull requests close: only a new candidate - # commit leaves them behind. + # commit (and, if the candidate's own was one, a new pull request to + # main, which prepare opens) leaves them behind. print( - f"::error::{branch} is not promoted: pull requests " - f"{', '.join(shared)} from it run on the same commit, and GitHub " - "does not record which pull request a run is for; close them and " - "requalify the pair" + f"::error::{branch} is not promoted: runs at its commit may have " + f"tested another base, from pull requests {', '.join(shared)}, and " + "GitHub does not record which pull request or base a run is for; " + "close them and requalify the pair" ) _set_ready(target, False) return False diff --git a/tests/qualification_fixtures.py b/tests/qualification_fixtures.py index 4e4d265ac..d7177c9f7 100644 --- a/tests/qualification_fixtures.py +++ b/tests/qualification_fixtures.py @@ -118,5 +118,79 @@ def jq(expression: str, data) -> str: ).stdout +def _field(argv, name): + """The value of gh's ``-f name=value`` / ``-F name=value`` in ``argv``.""" + + for flag, value in zip(argv, argv[1:]): + if flag in ("-f", "-F") and value.startswith(f"{name}="): + return value.split("=", 1)[1] + return None + + +def rest_pulls_output(argv, listing, owner="PolicyEngine") -> str: + """What `gh api -X GET repos//pulls -f head=: ...` + prints for ``listing`` (gh pr list-shaped items: number, headRefName, + headRefOid, baseRefName (main), state (OPEN, CLOSED or MERGED), isDraft, + isCrossRepository): GitHub keeps only pull requests from ``owner``'s + branch, then filters by base and state, and the call's --jq runs over + REST-shaped objects with the real jq.""" + + head = _field(argv, "head") + head_owner, _, branch = head.partition(":") + base = _field(argv, "base") + state = _field(argv, "state") or "open" + page = [] + for pr in listing: + pr = {"baseRefName": "main", "state": "OPEN", "isDraft": True, **pr} + pr_owner = "someone" if pr.get("isCrossRepository") else owner + rest_state = "open" if pr["state"] == "OPEN" else "closed" + if ( + pr_owner != head_owner + or pr["headRefName"] != branch + or (base is not None and pr["baseRefName"] != base) + or (state != "all" and rest_state != state) + ): + continue + page.append( + { + "number": pr["number"], + "state": rest_state, + "draft": pr["isDraft"], + "merged_at": "2026-10-16T00:00:00Z" + if pr["state"] == "MERGED" + else None, + "base": {"ref": pr["baseRefName"]}, + "head": { + "ref": pr["headRefName"], + "sha": pr.get("headRefOid"), + "repo": {"full_name": f"{pr_owner}/policyengine.py"}, + }, + } + ) + return jq(argv[argv.index("--jq") + 1], page) + + +def graphql_base_changes_output(argv, changed) -> str: + """What the base-change GraphQL query prints for the pull request it + names, ``changed`` being the numbers whose base was ever changed.""" + + number = int(_field(argv, "number")) + page = { + "data": { + "repository": { + "pullRequest": { + "timelineItems": { + "nodes": [{"__typename": "BaseRefChangedEvent"}] + if number in changed + else [], + "pageInfo": {"hasNextPage": False}, + } + } + } + } + } + return jq(argv[argv.index("--jq") + 1], page) + + def golden(name: str) -> dict: return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index b0c00d083..063f3bb1a 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -52,9 +52,11 @@ VENDORED_SCHEMAS, gates, golden, + graphql_base_changes_output, jq, make_pair, make_receipt, + rest_pulls_output, sha, ) @@ -1321,14 +1323,16 @@ def run(argv, **kwargs): calls.append(argv) out = "" code = 0 - if argv[:2] == ["gh", "api"]: + if argv[:3] == ["gh", "api", "graphql"]: + out = graphql_base_changes_output(argv, set()) + elif argv[:2] == ["gh", "api"] and argv[4:5] == [f"{_REPO}/pulls"]: + out = rest_pulls_output(argv, [target]) + elif argv[:2] == ["gh", "api"]: printed = _gh_api_output(argv, checks, runs, commit, branch) if printed is None: code = 1 else: out = printed - elif argv[:3] == ["gh", "pr", "list"]: - out = _gh_pr_list_output(argv, [target]) elif argv[:3] == ["git", "rev-parse", "FETCH_HEAD"]: out = commit if at_commit else "f" * 40 elif argv[:3] == ["git", "rev-parse", f"{commit}^"]: @@ -1392,6 +1396,7 @@ def test_promotion_returns_every_other_ready_candidate_to_draft( SUPERSEDED_PAIR = make_pair(model="2.0.0", data="1.0.0", core="3.0.0") +_REPO = "repos/PolicyEngine/policyengine.py" def _sweep_cli( @@ -1404,7 +1409,8 @@ def _sweep_cli( push_code=1, extra=(), superseded=False, - shared_list_fails=False, + shared_fails=None, + base_changed=(), ): """The promote CLI over a real `gh pr list` payload: the target (#1, ready), a stale same-repository candidate (#2, ready), a draft (#3), a @@ -1415,8 +1421,9 @@ def _sweep_cli( receipt for an older pair with an open pull request (#9), which the plan closes. ``failing`` names the pull request numbers whose undo fails; ``path`` is "main moved", "lease rejected" or "main at the parent"; - ``shared_list_fails`` fails the listing of the target branch's pull - requests.""" + ``base_changed`` names the pull requests whose base was ever changed; + ``shared_fails`` fails the REST listing of a branch's pull requests + ("listing") or the base-change query ("history").""" import importlib.util @@ -1501,10 +1508,17 @@ def run(argv, **kwargs): calls.append(argv) out, code = "", 0 if argv[:3] == ["gh", "pr", "list"]: - if shared_list_fails and "--head" in argv: + out = _gh_pr_list_output(argv, listing) + elif argv[:3] == ["gh", "api", "graphql"]: + if shared_fails == "history": + code = 1 + else: + out = graphql_base_changes_output(argv, set(base_changed)) + elif argv[:2] == ["gh", "api"] and argv[4:5] == [f"{_REPO}/pulls"]: + if shared_fails == "listing": code = 1 else: - out = _gh_pr_list_output(argv, listing) + out = rest_pulls_output(argv, listing) elif argv[:2] == ["gh", "api"]: out = _gh_api_output( argv, checks or CHECKS_PASSED, runs or RUNS_PASSED, commit, branch @@ -1611,34 +1625,83 @@ def run(argv, **kwargs): "isCrossRepository": False, } -# Each: another pull request from the target's branch (over OTHER_BASE); -# whether promotion refuses the target because its runs are shared. + +def _other(**fields): + return {**OTHER_BASE, **fields} + + +FORKS = [ + _other(number=100 + n, baseRefName="main", isCrossRepository=True) + for n in range(40) +] + +# Each: other pull requests from the target's branch, the numbers whose base +# was ever changed, whether promotion refuses the target, and the pull +# request the refusal names. SHARED_BRANCH_CASES = { - "open, to another base": ({}, True), + "open, to another base": ([_other()], (), True, 7), "open, to another base, its head not yet updated": ( - {"headRefOid": "a" * 40}, - True, - ), - "closed at the commit, to another base": ({"state": "CLOSED"}, True), - "merged at the commit, to another base": ({"state": "MERGED"}, True), - "open, to another base, without the fork field": ( - {"isCrossRepository": None}, + [_other(headRefOid="a" * 40)], + (), True, + 7, ), + "closed at the commit, to another base": ([_other(state="CLOSED")], (), True, 7), + "merged at the commit, to another base": ([_other(state="MERGED")], (), True, 7), "closed at another commit, to another base": ( - {"state": "CLOSED", "headRefOid": "a" * 40}, + [_other(state="CLOSED", headRefOid="a" * 40)], + (), False, + None, + ), + "a fork's, open, to another base": ( + [_other(isCrossRepository=True)], + (), + False, + None, ), - "a fork's, open, to another base": ({"isCrossRepository": True}, False), "closed at the commit, to main": ( - {"state": "CLOSED", "baseRefName": "main"}, + [_other(state="CLOSED", baseRefName="main")], + (), + False, + None, + ), + # Round 7 of #635: a base change does not change what a pull request's + # earlier runs tested, so one now to main that ever had another base + # counts, the candidate's own included. + "closed at the commit, to main after a base change": ( + [_other(state="CLOSED", baseRefName="main")], + (7,), + True, + 7, + ), + "closed at another commit, to main after a base change": ( + [_other(state="CLOSED", baseRefName="main", headRefOid="a" * 40)], + (7,), False, + None, ), + "the candidate's own, to main after a base change": ([], (1,), True, 1), + # Round 7: forks' pull requests with the branch's name, however many, + # neither crowd out nor stand in for this repository's. + "forty forks', to main, with its branch name": (FORKS, (), False, None), + "forty forks' and one to another base": (FORKS + [_other()], (), True, 7), } +def _run_reads(calls): + """The calls that read a commit's runs or checks.""" + + return [ + argv + for argv in calls + if argv[:2] == ["gh", "api"] + and ("actions/runs" in argv[2] or "check-runs" in argv[2]) + ] + + @pytest.mark.parametrize("case", sorted(SHARED_BRANCH_CASES)) -def test_a_candidate_whose_branch_has_another_pull_request_is_refused( +def test_a_candidate_whose_runs_may_have_tested_another_base_is_refused( monkeypatch, tmp_path, capsys, case ): """Round 6 of #635: a pull request from the candidate branch to another @@ -1647,20 +1710,21 @@ def test_a_candidate_whose_branch_has_another_pull_request_is_refused( failure could strand the candidate and its success hide the candidate's failure. While one is open, or after one closed at this commit, the candidate is kept draft and the run fails with an error naming it; the - other pull request is never readied, undone or counted as a candidate.""" + other pull request is never readied, undone or counted as a candidate. + Round 7: the same holds for a pull request now to main whose base was + ever changed, and forks' pull requests cannot crowd out the listing of + this repository's.""" import argparse - overrides, refused = SHARED_BRANCH_CASES[case] - other = {**OTHER_BASE, **overrides} - if other["isCrossRepository"] is None: - del other["isCrossRepository"] + extra, base_changed, refused, named = SHARED_BRANCH_CASES[case] cli, calls, branch = _sweep_cli( monkeypatch, path="main at the parent", failing=set(), push_code=0, - extra=[other], + extra=extra, + base_changed=base_changed, ) assert 7 not in [pr["number"] for pr in cli._candidate_pull_requests()] status = cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) @@ -1670,53 +1734,37 @@ def test_a_candidate_whose_branch_has_another_pull_request_is_refused( assert "7" not in readiness if refused: assert status == 1 and not pushes - assert f"::error::{branch} is not promoted" in output and "#7" in output + assert f"::error::{branch} is not promoted" in output + assert f"#{named} (" in output assert readiness == ["2", "1"] - assert not [argv for argv in calls if argv[:2] == ["gh", "api"]] + assert not _run_reads(calls) else: assert status == 0 and len(pushes) == 1 assert f"promoted {branch}" in output -@pytest.mark.parametrize("how", ["the listing fails", "the listing is full"]) +@pytest.mark.parametrize("how", ["listing", "history"]) def test_unreadable_pull_requests_from_the_candidate_branch_wait( monkeypatch, tmp_path, capsys, how ): - """Round 6 of #635: when the candidate branch's pull requests cannot all - be read (the call fails, or returns SAME_BRANCH_LIMIT of them), the - candidate waits as a draft without reading its runs.""" + """Round 6 of #635: when the candidate branch's pull requests (round 7: + or a base-change history) cannot be read, the candidate waits as a draft + without reading its runs.""" import argparse - extra = ( - [ - { - "number": 100 + n, - "headRefName": "{branch}", - "headRefOid": "a" * 40, - "state": "CLOSED", - "isDraft": True, - "isCrossRepository": False, - } - for n in range(99) - ] - if how == "the listing is full" - else [] - ) cli, calls, branch = _sweep_cli( monkeypatch, path="main at the parent", failing=set(), push_code=0, - extra=extra, - shared_list_fails=how == "the listing fails", + shared_fails=how, ) - assert cli.SAME_BRANCH_LIMIT == 100 assert cli.cmd_promote(argparse.Namespace(ledger=str(tmp_path), apply=True)) == 0 output = capsys.readouterr().out assert "could not be read" in output and "::error::" not in output assert not [argv for argv in calls if argv[:2] == ["git", "push"]] - assert not [argv for argv in calls if argv[:2] == ["gh", "api"]] + assert not _run_reads(calls) assert [argv[3] for argv in calls if argv[:3] == ["gh", "pr", "ready"]] == [ "2", "1", @@ -2107,41 +2155,52 @@ def test_a_forced_rerun_stages_a_new_attempt(tmp_path): assert attempt_key(pair, tmp_path, base_commit=base, force=True) == f"{base[:12]}-3" -#: Source for the fake gh's `pr list`, embedded in the workflow shell tests: -#: filters the listing by --base, --head (the BRANCH environment variable's -#: branch unless an item names another) and --state as gh does, cuts each -#: item to the --json fields and applies the call's --jq with the real jq. -FAKE_GH_PR_LIST = """ +#: Source for the fake gh's REST pull request listing, embedded in the +#: workflow shell tests (they run gh in another process): `pulls(args, +#: listing)` answers `gh api -X GET repos//pulls -f head=: +#: -f base=... -f state=...` as GitHub does, keeping only pull requests from +#: that owner's branch (a fork's has another owner; an item's branch is the +#: BRANCH environment variable's unless it names another), and applies the +#: call's --jq to REST-shaped objects with the real jq. +FAKE_GH_PULLS = """ import shutil import subprocess -def pr_list(args, listing): - def option(name, default=None): - return args[args.index(name) + 1] if name in args else default - - base, head, state = option("--base"), option("--head"), option("--state", "open") - rows = [ - {"headRefName": os.environ["BRANCH"], "state": "OPEN", "isDraft": True, **pr} - for pr in listing - ] - rows = [ - pr - for pr in rows - if (base is None or pr["baseRefName"] == base) - and (head is None or pr["headRefName"] == head) - and (state == "all" or pr["state"] == state.upper()) - ] - fields = option("--json").split(",") - rows = [{k: pr[k] for k in fields if k in pr} for pr in rows] - expression = option("--jq") - if expression is None: - print(json.dumps(rows)) - return +def pulls(args, listing): + fields = dict( + value.split("=", 1) for flag, value in zip(args, args[1:]) if flag == "-f" + ) + owner, _, branch = fields["head"].partition(":") + page = [] + for pr in listing: + pr = { + "headRefName": os.environ["BRANCH"], + "state": "OPEN", + "isDraft": True, + **pr, + } + pr_owner = "someone" if pr.get("isCrossRepository") else "PolicyEngine" + state = "open" if pr["state"] == "OPEN" else "closed" + if ( + pr_owner == owner + and pr["headRefName"] == branch + and pr["baseRefName"] == fields.get("base", pr["baseRefName"]) + and fields.get("state", "open") in ("all", state) + ): + page.append( + { + "number": pr["number"], + "state": state, + "draft": pr["isDraft"], + "base": {"ref": pr["baseRefName"]}, + "head": {"ref": pr["headRefName"]}, + } + ) sys.stdout.write( subprocess.run( - [shutil.which("jq"), "-r", expression], - input=json.dumps(rows), + [shutil.which("jq"), "-r", args[args.index("--jq") + 1]], + input=json.dumps(page), capture_output=True, text=True, check=True, @@ -2149,6 +2208,18 @@ def option(name, default=None): ) """ +#: Forks' pull requests with the candidate branch's name, newer than its own: +#: gh pr list's default listing of 30 would hold only these (round 7 of #635). +FORK_PULLS = [ + { + "number": 600 + n, + "baseRefName": "main", + "isCrossRepository": True, + "isDraft": False, + } + for n in range(40) +] + @pytest.mark.parametrize("step_name", ["open", "verdict"]) @pytest.mark.parametrize("exists", [True, False]) @@ -2175,7 +2246,7 @@ def test_the_workflow_writes_to_the_candidates_own_pull_request_by_number( step = next(s for s in workflow["jobs"][job]["steps"] if s.get("name") == name) log_path = tmp_path / "commands.jsonl" listing = [ - {"number": 575, "baseRefName": "main", "isCrossRepository": True}, + *FORK_PULLS, {"number": 576, "baseRefName": "release", "isCrossRepository": False}, { "number": 577, @@ -2185,8 +2256,8 @@ def test_the_workflow_writes_to_the_candidates_own_pull_request_by_number( }, ] if exists: - listing.insert( - 1, {"number": 574, "baseRefName": "main", "isCrossRepository": False} + listing.append( + {"number": 574, "baseRefName": "main", "isCrossRepository": False} ) fake = f"""#!{sys.executable} import json @@ -2194,12 +2265,12 @@ def test_the_workflow_writes_to_the_candidates_own_pull_request_by_number( import sys from pathlib import Path -{FAKE_GH_PR_LIST} +{FAKE_GH_PULLS} args = sys.argv[1:] with Path(os.environ["COMMAND_LOG"]).open("a") as log: log.write(json.dumps(["gh", *args]) + "\\n") -if args[:2] == ["pr", "list"]: - pr_list(args, {listing!r}) +if args[:1] == ["api"] and "repos/PolicyEngine/policyengine.py/pulls" in args: + pulls(args, {listing!r}) """ binary_dir = tmp_path / "bin" binary_dir.mkdir() @@ -2269,23 +2340,19 @@ def test_the_workflow_drafts_existing_prs_before_replacing_their_head( import sys from pathlib import Path -{FAKE_GH_PR_LIST} +{FAKE_GH_PULLS} command = Path(sys.argv[0]).name args = sys.argv[1:] state_path = Path(os.environ["PR_STATE"]) state = json.loads(state_path.read_text()) with Path(os.environ["COMMAND_LOG"]).open("a") as log: log.write(json.dumps([command, *args]) + "\\n") -if command == "gh" and args[:2] == ["pr", "list"]: - # A fork's ready pull request with the branch's name, and one of this - # repository's from the branch to another base, are never undone. +if command == "gh" and args[:1] == ["api"]: + # Forty newer ready forks' pull requests with the branch's name, and one + # of this repository's from the branch to another base, are never undone + # and never hide the candidate's own (round 7 of #635). listing = [ - {{ - "number": 575, - "baseRefName": "main", - "isCrossRepository": True, - "isDraft": False, - }}, + *{FORK_PULLS!r}, {{ "number": 576, "baseRefName": "release", @@ -2294,16 +2361,15 @@ def test_the_workflow_drafts_existing_prs_before_replacing_their_head( }}, ] if state["status"] != "absent": - listing.insert( - 0, + listing.append( {{ "number": 574, "baseRefName": "main", "isCrossRepository": False, "isDraft": state["status"] != "ready", - }}, + }} ) - pr_list(args, listing) + pulls(args, listing) elif command == "gh" and args[:2] == ["pr", "ready"]: assert args[2:] == ["574", "--undo"] if os.environ["RESET_FAILS"] == "1": @@ -2328,6 +2394,7 @@ def test_the_workflow_drafts_existing_prs_before_replacing_their_head( "PATH": f"{binary_dir}{os.pathsep}{os.environ['PATH']}", "BRANCH": "candidate/uk/2.126.8-1.58.0", "PAIR_ID": "a" * 64, + "GITHUB_REPOSITORY": "PolicyEngine/policyengine.py", "GH_TOKEN": "local-workflow-test", "PR_STATE": str(state_path), "COMMAND_LOG": str(log_path), diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 1c0289623..03efb5544 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -19,7 +19,16 @@ candidate_branch, previous_certificate, ) -from tests.qualification_fixtures import HARNESS, gates, make_pair, make_receipt +from tests.qualification_fixtures import ( + HARNESS, + gates, + graphql_base_changes_output, + make_pair, + make_receipt, + rest_pulls_output, +) + +PULLS = ["repos/PolicyEngine/policyengine.py/pulls"] REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW_PATH = REPO_ROOT / ".github/workflows/certify-uk-release.yaml" @@ -275,7 +284,9 @@ def test_head_replacement_and_readiness_share_one_serialization( path = Path(os.environ["PR_STATE"]) state = json.loads(path.read_text()) command, args = Path(sys.argv[0]).name, sys.argv[1:] -if command == "gh" and args[:2] == ["pr", "list"]: +if command == "gh" and args[:1] == ["api"] and "--paginate" in args: + # The REST listing of this repository's pull request to main, through + # the step's jq (only a ready one's number is printed). if state["status"] == "ready": print("574") elif command == "gh" and args[:2] == ["pr", "ready"]: @@ -316,6 +327,7 @@ def prepare(): "BRANCH": branch, "PAIR_ID": qualified.pair_id, "PR_STATE": str(state_path), + "GITHUB_REPOSITORY": "PolicyEngine/policyengine.py", }, check=True, capture_output=True, @@ -566,10 +578,15 @@ def test_refresh_checks_reruns_only_failed_pr_runs_for_the_qualified_head( def fake_run(argv, **kwargs): calls.append(argv) - if argv[:3] == ["gh", "pr", "list"]: - assert argv[argv.index("--head") + 1] == branch + if argv[:3] == ["gh", "api", "graphql"]: + return SimpleNamespace( + returncode=0, stdout=graphql_base_changes_output(argv, set()) + ) + if argv[:2] == ["gh", "api"] and argv[4:5] == PULLS: + assert f"head=PolicyEngine:{branch}" in argv return SimpleNamespace( - returncode=0, stdout=json.dumps([_candidate_pr(1, branch, head)]) + returncode=0, + stdout=rest_pulls_output(argv, [_candidate_pr(1, branch, head)]), ) if argv[:2] == ["gh", "api"]: assert "--paginate" in argv @@ -675,13 +692,13 @@ def test_refresh_reruns_each_candidate_only_its_own_runs(monkeypatch, tmp_path, def fake_run(argv, **kwargs): calls.append(argv) - if argv[:3] == ["gh", "pr", "list"]: - head_branch = argv[argv.index("--head") + 1] + if argv[:3] == ["gh", "api", "graphql"]: return SimpleNamespace( - returncode=0, - stdout=json.dumps( - [pr for pr in pull_requests if pr["headRefName"] == head_branch] - ), + returncode=0, stdout=graphql_base_changes_output(argv, set()) + ) + if argv[:2] == ["gh", "api"] and argv[4:5] == PULLS: + return SimpleNamespace( + returncode=0, stdout=rest_pulls_output(argv, pull_requests) ) if argv[:2] == ["gh", "api"]: if unreadable.pop("once", False): @@ -699,15 +716,19 @@ def fake_run(argv, **kwargs): assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] -@pytest.mark.parametrize("shared", ["another base", "unreadable"]) +@pytest.mark.parametrize( + "shared", ["another base", "a base change", "unreadable", "unreadable history"] +) def test_refresh_leaves_a_candidate_whose_branch_runs_are_shared( monkeypatch, tmp_path, capsys, shared ): """Round 6 of #635: a pull request from the candidate branch to another base runs on the same commit and branch, so the candidate's runs cannot be told from its runs; refresh re-runs none of them (promotion refuses - the candidate anyway), and the next candidate is still refreshed. The - same holds while that branch's pull requests cannot be read.""" + the candidate anyway), and the next candidate is still refreshed. Round + 7: so does the candidate's own pull request once its base was changed. + The same holds while that branch's pull requests, or their base history, + cannot be read.""" cli = _cli() first = make_receipt(pair=make_pair(model="2.200.0")) @@ -735,14 +756,22 @@ def test_refresh_leaves_a_candidate_whose_branch_runs_are_shared( def fake_run(argv, **kwargs): calls.append(argv) - if argv[:3] == ["gh", "pr", "list"]: - head_branch = argv[argv.index("--head") + 1] - if head_branch == first_branch and shared == "unreadable": + if argv[:3] == ["gh", "api", "graphql"]: + number = int(next(v for v in argv if v.startswith("number="))[7:]) + if number == 1 and shared == "unreadable history": return SimpleNamespace(returncode=1, stdout="") - listing = [pr for pr in pull_requests if pr["headRefName"] == head_branch] - if head_branch == first_branch: - listing.append(other) - return SimpleNamespace(returncode=0, stdout=json.dumps(listing)) + changed = {1} if shared == "a base change" else set() + return SimpleNamespace( + returncode=0, stdout=graphql_base_changes_output(argv, changed) + ) + if argv[:2] == ["gh", "api"] and argv[4:5] == PULLS: + if f"head=PolicyEngine:{first_branch}" in argv and shared == "unreadable": + return SimpleNamespace(returncode=1, stdout="") + listing = [*pull_requests, other] if shared == "another base" else [] + return SimpleNamespace( + returncode=0, + stdout=rest_pulls_output(argv, listing or pull_requests), + ) if argv[:2] == ["gh", "api"]: return SimpleNamespace( returncode=0, stdout="\n".join(json.dumps(run) for run in runs) @@ -753,11 +782,15 @@ def fake_run(argv, **kwargs): args = cli.parser().parse_args(["refresh-pr-checks", "--ledger", str(tmp_path)]) assert args.func(args) == 0 output = capsys.readouterr().out - if shared == "unreadable": + if shared.startswith("unreadable"): assert f"::warning::could not read the pull requests from {first_branch}" in ( output ) else: assert f"::warning::not re-running {first_branch}'s runs" in output - assert "#7 (to release, open)" in output + assert ( + "#7 (to release, open)" + if shared == "another base" + else "#1 (to main after a base change, open)" + ) in output assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] From 68446e2ff64c0c249a39fe9e27d50689a023f566 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 08:39:38 -0400 Subject: [PATCH 14/18] Count GitHub's automatic retargets as base changes Round 8 of #635: GitHub records a retarget it makes itself, when a pull request's base branch is merged and deleted, as an AutomaticBaseChangeSucceededEvent, which a query for BASE_REF_CHANGED_EVENT alone misses (live: #244). The base-change query now asks for both, so a pull request automatically retargeted to main is counted like one a person retargeted. The GraphQL fake now answers only for the event types a query asks for, and new cases cover an automatic retarget of the candidate's own and of a closed pull request; dropping either event type from the query fails a test. Co-Authored-By: Claude Opus 5.5 --- scripts/check_uk_candidate.py | 12 +++++++----- tests/qualification_fixtures.py | 26 +++++++++++++++++++++---- tests/test_uk_qualification_pipeline.py | 16 ++++++++++++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index fd0914f8a..f37e4076f 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -599,14 +599,16 @@ def _candidate_pull_requests() -> list[dict[str, Any]]: ] -#: Whether a pull request's base was ever changed: GraphQL records each change -#: as a BaseRefChangedEvent (its timeline's totalCount ignores the type -#: filter, so the nodes are read). +#: Whether a pull request's base was ever changed: GraphQL records a change +#: a person makes as a BaseRefChangedEvent and one GitHub makes when the base +#: branch is merged and deleted as an AutomaticBaseChangeSucceededEvent +#: (round 8 of #635). Its timeline's totalCount ignores the type filter, so +#: the nodes are read. BASE_CHANGES_QUERY = ( "query($owner: String!, $name: String!, $number: Int!) { repository(owner:" " $owner, name: $name) { pullRequest(number: $number) { timelineItems(" - "itemTypes: [BASE_REF_CHANGED_EVENT], first: 1) { nodes { __typename }" - " pageInfo { hasNextPage } } } } }" + "itemTypes: [BASE_REF_CHANGED_EVENT, AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT]," + " first: 1) { nodes { __typename } pageInfo { hasNextPage } } } } }" ) diff --git a/tests/qualification_fixtures.py b/tests/qualification_fixtures.py index d7177c9f7..bf74fc960 100644 --- a/tests/qualification_fixtures.py +++ b/tests/qualification_fixtures.py @@ -170,18 +170,36 @@ def rest_pulls_output(argv, listing, owner="PolicyEngine") -> str: return jq(argv[argv.index("--jq") + 1], page) +#: Each kind of base change and the timeline event GitHub records for it. +BASE_CHANGE_EVENTS = { + "manual": ("BASE_REF_CHANGED_EVENT", "BaseRefChangedEvent"), + "automatic": ( + "AUTOMATIC_BASE_CHANGE_SUCCEEDED_EVENT", + "AutomaticBaseChangeSucceededEvent", + ), +} + + def graphql_base_changes_output(argv, changed) -> str: """What the base-change GraphQL query prints for the pull request it - names, ``changed`` being the numbers whose base was ever changed.""" - + names. ``changed`` maps numbers to the kind of base change they had + ("manual" or "automatic"; a set of numbers means manual). As in GitHub, + a pull request's event is among the nodes only when the query's + itemTypes ask for its type (round 8 of #635: an automatic retarget is a + separate event type that a query for BASE_REF_CHANGED_EVENT misses).""" + + query = _field(argv, "query") + types = set(query[query.index("itemTypes: [") + 12 :].split("]")[0].split(", ")) + kinds = changed if isinstance(changed, dict) else dict.fromkeys(changed, "manual") number = int(_field(argv, "number")) + item_type, typename = BASE_CHANGE_EVENTS.get(kinds.get(number), (None, None)) page = { "data": { "repository": { "pullRequest": { "timelineItems": { - "nodes": [{"__typename": "BaseRefChangedEvent"}] - if number in changed + "nodes": [{"__typename": typename}] + if item_type in types else [], "pageInfo": {"hasNextPage": False}, } diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 063f3bb1a..4658492b1 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -1513,7 +1513,7 @@ def run(argv, **kwargs): if shared_fails == "history": code = 1 else: - out = graphql_base_changes_output(argv, set(base_changed)) + out = graphql_base_changes_output(argv, base_changed) elif argv[:2] == ["gh", "api"] and argv[4:5] == [f"{_REPO}/pulls"]: if shared_fails == "listing": code = 1 @@ -1682,6 +1682,20 @@ def _other(**fields): None, ), "the candidate's own, to main after a base change": ([], (1,), True, 1), + # Round 8: GitHub retargets a pull request itself when its base branch is + # merged and deleted, and records that as a separate event type. + "the candidate's own, to main after an automatic base change": ( + [], + {1: "automatic"}, + True, + 1, + ), + "closed at the commit, to main after an automatic base change": ( + [_other(state="CLOSED", baseRefName="main")], + {7: "automatic"}, + True, + 7, + ), # Round 7: forks' pull requests with the branch's name, however many, # neither crowd out nor stand in for this repository's. "forty forks', to main, with its branch name": (FORKS, (), False, None), From 18528c598193b15f6999d04bc7d60fc43f7fc850 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 09:23:40 -0400 Subject: [PATCH 15/18] Count every other pull request from the candidate branch, whatever its base Round 9 of #635: a base branch renamed into main records no timeline event, so a pull request now to main proves nothing about the base its runs at the commit tested (the reviewer showed a rename masking a failed run with no event to see). Any other pull request from the candidate branch that is open, or was closed or merged at the commit, now counts whatever its base, and only the candidate's own pull request is checked for recorded base changes. Since prepare opens the candidate's pull request to main in the job that pushes the commit, and qualification needs that job to succeed, a pull request renamed into main after running at the commit always sits beside prepare's there, and one of them counts. New cases: a closed or merged pull request to main at the commit is refused, one at another commit is not; all 7 mutants of the rule fail the suites. Changelog and runbook updated. Co-Authored-By: Claude Opus 5.5 --- changelog.d/634.changed.md | 2 +- docs/engineering/runbooks/uk-qualification.md | 15 ++-- scripts/check_uk_candidate.py | 72 ++++++++++--------- tests/test_uk_qualification_pipeline.py | 19 ++++- tests/test_uk_qualification_workflow.py | 2 +- 5 files changed, 67 insertions(+), 43 deletions(-) diff --git a/changelog.d/634.changed.md b/changelog.d/634.changed.md index eed801260..b8cf823c5 100644 --- a/changelog.d/634.changed.md +++ b/changelog.d/634.changed.md @@ -1 +1 @@ -The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed. Runs of pull requests from other branches or forks at the same commit are not counted; a candidate whose runs may have tested another base (its branch has a pull request to another base that ran on its commit, or one now to main whose base was ever changed) is refused with an error, since GitHub does not record which pull request or base a run is for. Candidate pull requests are found through GitHub's listing of this repository's own branches, which forks' pull requests cannot crowd out, and every write to one goes by its number. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. +The UK qualification replay gate applies policy d1344-replay-regressions-v1. It fails lost results, lost responses (a measure-year the candidate lists as inert, or a measure-year total of £0 against a base at least the diff's bound away), mapping flags and incomplete grids. It sends every other flagged move to a named reviewer. Each receipt records the policy id and its text's SHA-256. Automatic UK promotion is on (d1344): a qualified candidate still built on current main is moved onto main, by a push leased on its parent, once every check of its own runs on its own tree has passed. Runs of pull requests from other branches or forks at the same commit are not counted; a candidate whose runs may not all be its own pull request's against main (its branch has another pull request that is open or ran at its commit, whatever that one's base, or its own pull request's base was ever changed) is refused with an error, since GitHub does not record which pull request or base a run is for, and a base renamed into main records no event. Candidate pull requests are found through GitHub's listing of this repository's own branches, which forks' pull requests cannot crowd out, and every write to one goes by its number. Refresh re-runs a candidate's failed, timed-out or cancelled runs up to four attempts within GitHub's 30-day window, and a candidate left waiting on a run it will not re-run fails the promote run with an error naming it. diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index d3268f3f2..38dfed74b 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -89,13 +89,14 @@ job fails on every scheduled run until one of these clears it: ### Another pull request from the candidate branch -A pull request from a candidate branch to a base other than main runs the -same workflows with the same commit and branch as the candidate's own pull -request, and GitHub's run records do not say which pull request, or which -base, a run is for. While such a pull request is open, or after one was -closed or merged at the candidate's commit, or when a pull request from the -branch now to main ever had another base (the candidate's own included), -the promote job fails with +Any other pull request from a candidate branch runs the same workflows with +the same commit and branch as the candidate's own pull request, and +GitHub's run records say neither which pull request a run is for nor which +base it tested; a pull request's current base does not settle it, since a +base renamed into main records no event. So while another pull request from +the branch is open, or after one was closed or merged at the candidate's +commit (whatever its base), or when the candidate's own pull request's base +was ever changed, the promote job fails with `::error:: is not promoted: runs at its commit may have tested another base`, naming the pull requests, and refresh re-runs none of the candidate's runs. Close those pull requests, then requalify the pair as above: the new diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index f37e4076f..7e420cd84 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -642,23 +642,30 @@ def _base_changed(number: Any) -> Optional[bool]: return {"true": True, "false": False}.get(answer) -def _shared_branch_pull_requests(branch: str, commit: str) -> Optional[list[str]]: - """Pull requests from this repository's ``branch`` whose runs at - ``commit`` may have tested a base other than main; None if they cannot - all be read. - - A pull request from the candidate branch to another base runs the same - workflows with the same commit, branch and repository as the - candidate's own (round 6 of #635), and GitHub's run records do not say - which pull request started a run, nor which base it tested. So these - count: one open to another base, one closed or merged at this commit to - another base, and, among those to main, one whose base was ever changed, - since a base change does not change what its earlier runs tested - (round 7). Every other pull request from the branch to main is the - candidate's own: at most one is open, and a closed one's runs test the - same merge. The REST listing filtered by ``head=:`` holds - only this repository's pull requests, all of them (paginated), so a - fork's with the same branch name can neither shadow nor crowd them out. +def _shared_branch_pull_requests( + branch: str, commit: str, pull_request: str +) -> Optional[list[str]]: + """Why runs at ``commit`` may not all be the candidate's own pull + request's, against main: the pull requests from this repository's + ``branch`` that make them ambiguous; None if they cannot all be read. + + Any other pull request from the candidate branch runs the same workflows + with the same commit, branch and repository as the candidate's own, + ``pull_request`` (round 6 of #635), and GitHub's run records say neither + which pull request started a run nor which base it tested. A pull + request's current base does not settle it either: a base change keeps + the earlier runs (round 7), and a base branch renamed into main records + no event at all (round 9). So every other pull request from the branch + that is open, or was closed or merged at this commit, counts, whatever + its base, and so does the candidate's own if its base was ever changed. + + That covers a rename too. Prepare opens the candidate's pull request to + main in the job that pushes the commit, and qualification needs that job + to succeed, so a pull request whose base became main by a rename after + running at the commit sits beside prepare's at the commit, and one of + the two counts. The REST listing filtered by ``head=:`` + holds only this repository's pull requests, all of them (paginated), so + a fork's with the same branch name can neither shadow nor crowd them out. """ owner = LEDGER_REPOSITORY.split("/")[0] @@ -690,20 +697,19 @@ def _shared_branch_pull_requests(branch: str, commit: str) -> Optional[list[str] listing = [json.loads(line) for line in result.stdout.splitlines() if line] except json.JSONDecodeError: return None - shared = [] - for pr in listing: - if pr.get("state") != "open" and pr.get("head_sha") != commit: - continue - state = "merged" if pr.get("merged") else str(pr.get("state")) - if pr.get("base") != "main": - shared.append(f"#{pr.get('number')} (to {pr.get('base')}, {state})") - continue - changed = _base_changed(pr.get("number")) - if changed is None: - return None - if changed: - shared.append(f"#{pr.get('number')} (to main after a base change, {state})") - return sorted(shared) + shared = sorted( + f"#{pr.get('number')} (to {pr.get('base')}, " + f"{'merged' if pr.get('merged') else pr.get('state')})" + for pr in listing + if str(pr.get("number")) != pull_request + and (pr.get("state") == "open" or pr.get("head_sha") == commit) + ) + changed = _base_changed(pull_request) + if changed is None: + return None + if changed: + shared.append(f"#{pull_request} (the candidate's own, after a base change)") + return shared #: A run is re-run until it has made this many attempts in all, so a genuine @@ -837,7 +843,7 @@ def cmd_refresh_pr_checks(args: argparse.Namespace) -> int: or pull_request["headRefOid"] != head ): continue - shared = _shared_branch_pull_requests(branch, head) + shared = _shared_branch_pull_requests(branch, head, str(pull_request["number"])) if shared is None: print(f"::warning::could not read the pull requests from {branch}") continue @@ -1003,7 +1009,7 @@ def _promote( if not automatic: _set_ready(target, True) return True - shared = _shared_branch_pull_requests(branch, commit) + shared = _shared_branch_pull_requests(branch, commit, target) if shared is None: print( f"automatic promotion waits for {branch}: the pull requests from it " diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py index 4658492b1..d2d9a81dc 100644 --- a/tests/test_uk_qualification_pipeline.py +++ b/tests/test_uk_qualification_pipeline.py @@ -1660,9 +1660,26 @@ def _other(**fields): False, None, ), - "closed at the commit, to main": ( + # Round 9 of #635: a base branch renamed into main records no event, so + # a pull request now to main proves nothing about what its runs at the + # commit tested; any other pull request from the branch at the commit + # counts, whatever its base. (Prepare's own pull request to main is + # always at the commit too, so a renamed one sits beside it.) + "closed at the commit, to main (a rename leaves no event)": ( [_other(state="CLOSED", baseRefName="main")], (), + True, + 7, + ), + "merged at the commit, to main": ( + [_other(state="MERGED", baseRefName="main")], + (), + True, + 7, + ), + "closed at another commit, to main": ( + [_other(state="CLOSED", baseRefName="main", headRefOid="a" * 40)], + (), False, None, ), diff --git a/tests/test_uk_qualification_workflow.py b/tests/test_uk_qualification_workflow.py index 03efb5544..10f1c2a8b 100644 --- a/tests/test_uk_qualification_workflow.py +++ b/tests/test_uk_qualification_workflow.py @@ -791,6 +791,6 @@ def fake_run(argv, **kwargs): assert ( "#7 (to release, open)" if shared == "another base" - else "#1 (to main after a base change, open)" + else "#1 (the candidate's own, after a base change)" ) in output assert [call[3] for call in calls if call[:3] == ["gh", "run", "rerun"]] == ["32"] From 69d6f1fa69537918eb3a636920f18c2eb7f8ec7b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 09:53:02 -0400 Subject: [PATCH 16/18] State what automatic promotion trusts Round 10 of #635 modelled an admin renaming the default branch in the seconds between prepare's push and its pull request lookup, so that a run against another base passes as the candidate's. Every round since 6 has needed write or admin access here, and each confirmed that main only ever becomes the exact qualified commit, on current main, after its own-tree run passed. main is not protected (checked live: protected=false, no rulesets), so people who can write here can push it directly; closing this would need event-time provenance from pr_code_changes.yaml, which W4 does not own. The _promote docstring and the runbook now say what the gate guards against (mistakes by writers, anything non-writers can do) and what it does not (deliberate disguise by writers). Co-Authored-By: Claude Opus 5.5 --- docs/engineering/runbooks/uk-qualification.md | 13 +++++++++++++ scripts/check_uk_candidate.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index 38dfed74b..ac4a414d6 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -102,3 +102,16 @@ naming the pull requests, and refresh re-runs none of the candidate's runs. Close those pull requests, then requalify the pair as above: the new candidate commit leaves their runs behind, and prepare opens a new pull request to main if none is open. + +### What promotion trusts + +Promotion guards against mistakes by people who can write to this +repository and against anything people who cannot can do (forks' pull +requests, whatever their branch names). Whatever else happens, main only +ever becomes the exact qualified commit, built on main as it is at the push, +after the dispatched `pr_code_changes.yaml` run on that commit's own tree +passed. It does not try to resist deliberate disguise by people who can +write here, such as renaming another branch into main to make a run against +another base look like the candidate's: main is not protected, so they +could push it directly. Treat any such change as an incident, not as +something the gate will catch. diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index 7e420cd84..f5299a207 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -982,10 +982,20 @@ def _promote( request that a merge could combine with other code, when the candidate waits on runs refresh will not re-run again, or when its runs may have tested another base (``_shared_branch_pull_requests``); either way the - promote run - fails so someone sees it. ``pull_request`` names the + promote run fails so someone sees it. ``pull_request`` names the candidate's pull request for readiness changes (its number; the branch by default). + + What it guards against: mistakes by people who can write to this + repository, and anything people who cannot (forks' pull requests, + whatever branch names they use) can do. Whatever else happens, main + only ever becomes the exact qualified commit, built on main as it is at + the push, after the dispatched run on that commit's own tree passed. + It does not try to resist deliberate disguise by people who can write + here, such as renaming another branch into main or retargeting pull + requests so that a run against another base looks like the candidate's: + main is not protected, so they could push it directly. The pull request + checks it also requires are as trustworthy as those people. """ target = pull_request or branch From efd9d7c1cd9416dbb673d37cfc835a0795a32a24 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 10:18:44 -0400 Subject: [PATCH 17/18] Correct the shared-branch docstring to the trust boundary Round 11 of #635 (approved; low): the helper docstring still said prepare always opens a separate pull request, so a rename could not slip by; round 10 showed prepare's lookup can adopt a renamed one. It now says what the check cannot see and points to _promote's trust boundary. That boundary, here and in the runbook, now also names pull requests opened from the candidate branch by anyone who can read it (no write access needed), which make promotion refuse rather than accept. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/runbooks/uk-qualification.md | 4 +++- scripts/check_uk_candidate.py | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index ac4a414d6..1e4b8efc3 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -107,7 +107,9 @@ request to main if none is open. Promotion guards against mistakes by people who can write to this repository and against anything people who cannot can do (forks' pull -requests, whatever their branch names). Whatever else happens, main only +requests, whatever their branch names, and pull requests opened from a +candidate branch by anyone who can read it, which make promotion refuse +rather than accept). Whatever else happens, main only ever becomes the exact qualified commit, built on main as it is at the push, after the dispatched `pr_code_changes.yaml` run on that commit's own tree passed. It does not try to resist deliberate disguise by people who can diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index f5299a207..c03faa658 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -659,13 +659,14 @@ def _shared_branch_pull_requests( that is open, or was closed or merged at this commit, counts, whatever its base, and so does the candidate's own if its base was ever changed. - That covers a rename too. Prepare opens the candidate's pull request to - main in the job that pushes the commit, and qualification needs that job - to succeed, so a pull request whose base became main by a rename after - running at the commit sits beside prepare's at the commit, and one of - the two counts. The REST listing filtered by ``head=:`` - holds only this repository's pull requests, all of them (paginated), so - a fork's with the same branch name can neither shadow nor crowd them out. + What it cannot see: a base renamed into main after running at the + commit on the candidate's own pull request, including one prepare's + lookup adopted as the candidate's after the rename (round 10). Renaming + a branch into main is an admin's deliberate act, outside what promotion + guards against (``_promote``). The REST listing filtered by + ``head=:`` holds only this repository's pull requests, + all of them (paginated), so a fork's with the same branch name can + neither shadow nor crowd them out. """ owner = LEDGER_REPOSITORY.split("/")[0] @@ -987,8 +988,10 @@ def _promote( by default). What it guards against: mistakes by people who can write to this - repository, and anything people who cannot (forks' pull requests, - whatever branch names they use) can do. Whatever else happens, main + repository, and anything people who cannot can do (forks' pull requests, + whatever branch names they use, and pull requests opened from the + candidate branch by anyone who can read it, which make promotion refuse + rather than accept). Whatever else happens, main only ever becomes the exact qualified commit, built on main as it is at the push, after the dispatched run on that commit's own tree passed. It does not try to resist deliberate disguise by people who can write From 918939b93855b77d4b9a4102910d935c77458b83 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 10:25:59 -0400 Subject: [PATCH 18/18] Say how an adopted pull request is treated Round 12 of #635 (approved; low, wording): the trust boundary said pull requests opened from the candidate branch make promotion refuse, which holds for every other one but not for one to main that prepare's lookup adopts as the candidate's own; that one is refused only if its base was ever changed. The _promote docstring and the runbook now say so, in the reviewer's wording. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/runbooks/uk-qualification.md | 7 ++++--- scripts/check_uk_candidate.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/engineering/runbooks/uk-qualification.md b/docs/engineering/runbooks/uk-qualification.md index 1e4b8efc3..41e245ce4 100644 --- a/docs/engineering/runbooks/uk-qualification.md +++ b/docs/engineering/runbooks/uk-qualification.md @@ -107,9 +107,10 @@ request to main if none is open. Promotion guards against mistakes by people who can write to this repository and against anything people who cannot can do (forks' pull -requests, whatever their branch names, and pull requests opened from a -candidate branch by anyone who can read it, which make promotion refuse -rather than accept). Whatever else happens, main only +requests, whatever their branch names, and other pull requests opened from +a candidate branch, which make promotion refuse; one to main that prepare's +lookup adopts is the candidate's own, and promotion refuses it if its base +was ever changed). Whatever else happens, main only ever becomes the exact qualified commit, built on main as it is at the push, after the dispatched `pr_code_changes.yaml` run on that commit's own tree passed. It does not try to resist deliberate disguise by people who can diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py index c03faa658..d07fdfe5c 100644 --- a/scripts/check_uk_candidate.py +++ b/scripts/check_uk_candidate.py @@ -989,9 +989,10 @@ def _promote( What it guards against: mistakes by people who can write to this repository, and anything people who cannot can do (forks' pull requests, - whatever branch names they use, and pull requests opened from the - candidate branch by anyone who can read it, which make promotion refuse - rather than accept). Whatever else happens, main + whatever branch names they use, and other pull requests opened from the + candidate branch, which make promotion refuse; one to main that + prepare's lookup adopts is the candidate's own, and promotion refuses it + if its base was ever changed). Whatever else happens, main only ever becomes the exact qualified commit, built on main as it is at the push, after the dispatched run on that commit's own tree passed. It does not try to resist deliberate disguise by people who can write